Skip to content

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_id on 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

  1. Verify email ownership before lookup. The API cannot tell whether your app completed OTP.
  2. Keep verification and login tracking separate. verify_subscription is read-only. track_login records the login after ownership is proven.
  3. Save the exact user.user_id. Treat it as opaque: do not generate it, remove its u_ prefix, or replace it with another ID.
  4. Keep identity out of the link. The app persists clickId; only the authenticated backend binds it after email verification.

The Sequence

  1. If a Universal Link opened the app, record the app open and persist the returned clickId.
  2. The user signs in and completes OTP or equivalent verification.
  3. The backend binds any persisted clickId with the verified email.
  4. Verify the subscription with that same confirmed email.
  5. If active is true, track the login with the returned user.user_id.
  6. Save that ID on the authenticated app account.
  7. On later app opens, verify access again with the saved ID.

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. Persist the openId until the public app-open request succeeds, then persist its returned clickId.
  2. The user signs in and verifies their email through OTP or an equivalent method.
  3. The authenticated customer backend binds that clickId with the verified email through the private SDK route.
  4. Call verify_subscription with the same verified email.
  5. If active is true, call track_login with the returned user.user_id and 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

ResponseMeaningWhat to do
200, active: trueActive accessContinue
200, active: falseNo active accessDo not grant access
400Identity or publishable key is missingFix the request
401Invalid publishable keyFix the key
503, SDK_DEPENDENCY_UNAVAILABLEA subscription read dependency is temporarily unavailableWait for Retry-After, then retry the same request
500Unclassified server errorRetry 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

ResponseMeaningWhat to do
200, login.tracked: trueLogin recordedSave user.user_id
400userId or publishable key is missingFix the request
401Invalid publishable keyFix the key
404, Funnel user not foundWrong user ID or projectRe-resolve with verify_subscription; do not retry the same ID
404, No active subscription foundAccess is inactive or the billing webhook has not arrivedRetry briefly after checkout, then stop
500Server errorRetry 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 same clickId and 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.
  • 200 with active: false is handled as no access.
  • 409 is never treated as success.

For endpoint details, see the Public API.