Appearance
Email Attribution And iOS Universal Links Integration Guide
This guide connects a web-funnel purchase and an optional deterministic app-link click to an authenticated account. A verified email address proves identity; the link contributes a clickId, never identity.
Before You Start
You need:
- the project SDK publishable key from Project Settings;
- the project SDK secret key on the customer backend for exact click binding;
- an OTP or equivalent email-verification flow;
- a backend, Firebase custom claim, or equivalent place to store one FunnelsGrove
user_idon the authenticated account. - for iOS link attribution, a complete enabled app configuration and at least one enabled project link.
The two identity requests below send:
http
X-SDK-Publishable-Key: <project-sdk-publishable-key>The publishable key identifies the project. No additional scope parameter is needed. It is safe to distribute, but it is not proof of user identity.
Call the two identity endpoints from your authenticated backend. Verify the OTP result there, call FunnelsGrove, and return only the entitlement fields your app needs. This keeps your integration from trusting raw client input or relaying the expanded login response.
Four Rules
- Verify email ownership before lookup. The API cannot tell whether your app completed OTP.
- Keep verification and login tracking separate.
verify_subscriptionis read-only.track_loginrecords the login after ownership is proven. - Save the exact
user.user_id. Treat it as opaque: do not generate it, remove itsu_prefix, or replace it with another ID. - Keep identity out of the link. The app persists
clickId; only the authenticated backend binds it after email verification.
The Sequence
- If a Universal Link opened the app, record the app open and persist the returned
clickId. - The user signs in and completes OTP or equivalent verification.
- The backend binds any persisted
clickIdwith the verified email. - Verify the subscription with that same confirmed email.
- If
activeistrue, track the login with the returneduser.user_id. - Save that ID on the authenticated app account.
- On later app opens, verify access again with the saved ID.
Configure iOS Universal Links
Project links use one namespaced URL in funnels, email, and direct campaigns:
text
https://go.funnelsgrove.com/<app-key>/<slug>With the app installed, iOS opens it directly and the app records the open. Without the app installed, the resolver records the click and returns a direct 302 to the configured App Store URL. Follow iOS Universal Links for Associated Domains, app-open recording, exact backend binding, retries, and rollout testing.
The Two Requests
Verify Subscription
Pass either a known FunnelsGrove userId or an email whose ownership has already been verified.
By email:
http
POST https://sdk-api.funnelsgrove.com/sdk/public/users/verify_subscription
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"email": "verified-user@example.com"
}By saved user ID:
http
POST https://sdk-api.funnelsgrove.com/sdk/public/users/verify_subscription
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"userId": "u_123"
}The response contains the access flag, a test flag, and the stable public user ID. It does not expose the email, internal database ID, or subscription records. When the active subscription was purchased in Stripe test mode, test is true; never grant production access from a test subscription.
Example responses
Active access:
json
{
"active": true,
"test": false,
"user": {
"user_id": "u_123"
}
}No matching user:
json
{
"active": false,
"test": false,
"user": null
}Known user without active access:
json
{
"active": false,
"test": false,
"user": {
"user_id": "u_123"
}
}This request changes nothing and returns Cache-Control: no-store. Re-check at each entitlement boundary instead of caching active on the device.
Track Login
Call this only after OTP succeeds and subscription verification returns active: true.
http
POST https://sdk-api.funnelsgrove.com/sdk/public/users/track_login
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"userId": "u_123",
"runtimeEnvironment": "test"
}runtimeEnvironment is optional. Set it to live or test to check only that environment. If omitted, track_login checks live first and falls back to test only when live has no matching active subscription.
Fields used from a successful response
json
{
"user": {
"user_id": "u_123",
"email": "verified-user@example.com",
"subscriptionStatus": "active"
},
"active": true,
"activeUntil": "2026-09-01T00:00:00.000Z",
"login": {
"tracked": true,
"newlyTracked": true,
"subscriptionId": "subscription-1",
"trackedAt": "2026-08-05T10:00:00.000Z"
}
}The login response contains additional funnel and subscription context. Keep the call on your backend and return only the minimum fields required by the app.
login.tracked: true means success. login.newlyTracked is true on the first call and false on an idempotent replay. Treat both as success.
Persist the exact returned ID on your backend:
ts
await firebaseAdmin.auth().setCustomUserClaims(firebaseUid, {
funnelsGroveUserId: trackLoginResponse.user.user_id,
});Two Entry Cases
1. The App Recorded A Universal Link Open
- Persist the
openIduntil the public app-open request succeeds, then persist its returnedclickId. - The user signs in and verifies their email through OTP or an equivalent method.
- The authenticated customer backend binds that
clickIdwith the verified email through the private SDK route. - Call
verify_subscriptionwith the same verified email. - If
activeistrue, calltrack_loginwith the returneduser.user_idand save that ID on the authenticated account.
The URL and clickId never prove identity or grant access. They only preserve deterministic attribution until the backend has a verified account signal.
2. The App Has No Recorded Click
Continue with the same verified-email subscription lookup and login-tracking sequence. If the email matches an active funnel user, save the returned user.user_id; the install remains unattributed because no deterministic click handoff exists.
If active is false or user is null, do not track the login or unlock paid access. Ask the user to switch accounts or verify the subscription-owning email rather than transferring access from unverified input.
Later App Opens
Call verify_subscription with the ID saved on the authenticated account:
http
POST https://sdk-api.funnelsgrove.com/sdk/public/users/verify_subscription
Content-Type: application/json
X-SDK-Publishable-Key: <project-sdk-publishable-key>
{
"userId": "u_123"
}Grant paid access only while active is true.
Responses And Retries
The two endpoints report missing access differently.
verify_subscription
| Response | Meaning | What to do |
|---|---|---|
200, active: true | Active access | Continue |
200, active: false | No active access | Do not grant access |
400 | Identity or publishable key is missing | Fix the request |
401 | Invalid publishable key | Fix the key |
503, SDK_DEPENDENCY_UNAVAILABLE | A subscription read dependency is temporarily unavailable | Wait for Retry-After, then retry the same request |
500 | Unclassified server error | Retry with bounded backoff |
Immediately after checkout, billing webhook processing may briefly produce active: false. Retry with bounded backoff before deciding that access is inactive.
track_login
| Response | Meaning | What to do |
|---|---|---|
200, login.tracked: true | Login recorded | Save user.user_id |
400 | userId or publishable key is missing | Fix the request |
401 | Invalid publishable key | Fix the key |
404, Funnel user not found | Wrong user ID or project | Re-resolve with verify_subscription; do not retry the same ID |
404, No active subscription found | Access is inactive or the billing webhook has not arrived | Retry briefly after checkout, then stop |
500 | Server error | Retry with bounded backoff |
FunnelsGrove records registration_completed on the first successful login-tracking request. Replays reuse the same event identity, so bounded retries are safe.
Testing preview or test-mode purchases
A native request without a preview Origin checks live users and subscriptions. To test a preview purchase, call through your backend with that funnel's preview origin. Otherwise a preview-only user can return active: false even when its test subscription is active.
Verification Checklist
- OTP completes before any subscription lookup by email.
- Identity calls run through the authenticated backend; the app does not relay the full login response.
- Lookup uses the verified account email, not raw form input.
- An installed app records the Universal Link open and persists
clickId; an uninstalled device reaches the configured App Store URL. - Record retries reuse the same
openId; backend bind retries reuse the sameclickIdand normalized verified email. - No identity or server credential is placed in the URL or public app-open request.
- Login tracking runs only after verified ownership and
active: true. - The app stores the exact returned
user.user_id. - On later app opens, verify with the stored ID and do not cache
active. 200withactive: falseis handled as no access.409is never treated as success.
For endpoint details, see the Public API.