From 8020470a95871467469ae6a71e2e9f4b4e9e772b Mon Sep 17 00:00:00 2001 From: xantus smith <281845000+smith-909@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:48:07 -0700 Subject: [PATCH] feat(attribution): expose install match certainty on the deferred deep link The deferred deep link delivered to onDeferredDeepLink and returned by getInstallData now carries confidenceScore, matchedFactors, attributionMethod and competingLinks. Each is read from the install response's deepLinkData, falling back to the top level of the response, and stays undefined when the server sends neither. competingLinks counts the other links that had a click from the install's network which also reached the confidence threshold. Above 0, another device's tap may be what led to the install, so an app acting on a sensitive link can ask the user to confirm before trusting the match. InstallAttributionResponse gains optional attributionMethod and competingLinks, and the AttributionMethod type is exported. The mapping from install response to deferred deep link moves into deferredDeepLinkFromInstall so it is covered by unit tests. --- README.md | 21 +++++ __tests__/DeferredDeepLink.test.ts | 138 ++++++++++++++++++++++++++++- llms.txt | 14 +++ src/DeferredDeepLink.ts | 36 +++++++- src/LinkFortySDK.ts | 15 +--- src/index.ts | 1 + src/types.ts | 43 +++++++++ 7 files changed, 254 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bef2fd4..3b39985 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,18 @@ LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { |--------------------|------|-------------| | `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs | +The deferred link also carries how certain the match is: `confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` (see [`DeepLinkData`](#deeplinkdata)). Before acting on a sensitive link, check `competingLinks`: + +```typescript +LinkForty.onDeferredDeepLink((data) => { + if (data?.customParameters?.route === 'SIGN_IN' && (data.competingLinks ?? 0) > 0) { + // Another link was also in contention for this install: confirm with the user first + return askUserToConfirm(data); + } + // ... +}); +``` + --- ### `createLink(options)` @@ -381,9 +393,18 @@ interface DeepLinkData { appScheme?: string; clickedAt?: string; linkId?: string; + // Deferred deep links only (onDeferredDeepLink / getInstallData): + confidenceScore?: number; // 0-100 + matchedFactors?: string[]; // e.g. ['ip', 'user_agent', 'timezone'] + attributionMethod?: 'fingerprint' | 'none' | string; + competingLinks?: number; } ``` +`confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` are set only on the deferred deep link; direct opens leave them undefined, and servers that do not report `attributionMethod` or `competingLinks` leave those undefined too. + +`competingLinks` is how many other links had a click from the install's network that also reached the confidence threshold. `0` means no other link was in contention; above `0` means another device's tap may be what led to this install, so an app acting on a sensitive link (sign-in, gift) can treat the match as unverified, for example by asking the user to confirm. + ### `LinkFortyConfig` ```typescript diff --git a/__tests__/DeferredDeepLink.test.ts b/__tests__/DeferredDeepLink.test.ts index a0a6aa8..dbe66a1 100644 --- a/__tests__/DeferredDeepLink.test.ts +++ b/__tests__/DeferredDeepLink.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { DeferredDeepLink } from '../src/DeferredDeepLink'; -import type { DeepLinkData } from '../src/types'; +import { DeferredDeepLink, deferredDeepLinkFromInstall } from '../src/DeferredDeepLink'; +import type { DeepLinkData, InstallAttributionResponse } from '../src/types'; const data = { shortCode: 'abc123', linkId: 'link-1' } as DeepLinkData; @@ -41,3 +41,137 @@ describe('DeferredDeepLink', () => { expect(callback).not.toHaveBeenCalled(); }); }); + +describe('deferredDeepLinkFromInstall', () => { + const matched: InstallAttributionResponse = { + installId: 'install-1', + attributed: true, + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + deepLinkData: { + shortCode: 'abc123', + linkId: 'link-1', + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }, + }; + + it('passes the match certainty fields through to the deferred deep link', () => { + const link = deferredDeepLinkFromInstall(matched); + + expect(link).toMatchObject({ + shortCode: 'abc123', + linkId: 'link-1', + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }); + }); + + it('delivers the match certainty fields to the onDeferredDeepLink callback', () => { + const deferred = new DeferredDeepLink(); + const callback = vi.fn(); + deferred.register(callback); + deferred.deliver(deferredDeepLinkFromInstall(matched)); + + const delivered = callback.mock.calls[0][0] as DeepLinkData; + expect(delivered.confidenceScore).toBe(85); + expect(delivered.matchedFactors).toEqual(['ip', 'user_agent', 'timezone']); + expect(delivered.attributionMethod).toBe('fingerprint'); + expect(delivered.competingLinks).toBe(2); + }); + + it('keeps the fields through the JSON round trip used by getInstallData', () => { + const stored = JSON.parse(JSON.stringify(deferredDeepLinkFromInstall(matched))); + + expect(stored.attributionMethod).toBe('fingerprint'); + expect(stored.competingLinks).toBe(2); + expect(stored.confidenceScore).toBe(85); + expect(stored.matchedFactors).toEqual(['ip', 'user_agent', 'timezone']); + }); + + it('keeps competingLinks of 0', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + competingLinks: 0, + deepLinkData: { shortCode: 'abc123', attributionMethod: 'fingerprint', competingLinks: 0 }, + }); + + expect(link?.competingLinks).toBe(0); + }); + + it('leaves attributionMethod and competingLinks undefined when the server omits them', () => { + const link = deferredDeepLinkFromInstall({ + installId: 'install-1', + attributed: true, + confidenceScore: 85, + matchedFactors: ['ip'], + deepLinkData: { shortCode: 'abc123', confidenceScore: 85, matchedFactors: ['ip'] }, + }); + + expect(link?.attributionMethod).toBeUndefined(); + expect(link?.competingLinks).toBeUndefined(); + expect(link?.confidenceScore).toBe(85); + expect(link?.matchedFactors).toEqual(['ip']); + }); + + it('leaves all four fields undefined when the server sends none of them', () => { + const response = { + installId: 'install-1', + attributed: true, + deepLinkData: { shortCode: 'abc123' }, + } as unknown as InstallAttributionResponse; + const link = deferredDeepLinkFromInstall(response); + + expect(link?.shortCode).toBe('abc123'); + expect(link?.confidenceScore).toBeUndefined(); + expect(link?.matchedFactors).toBeUndefined(); + expect(link?.attributionMethod).toBeUndefined(); + expect(link?.competingLinks).toBeUndefined(); + }); + + it('falls back to the top-level fields when the deep link data lacks them', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + deepLinkData: { shortCode: 'abc123' }, + }); + + expect(link).toMatchObject({ + confidenceScore: 85, + matchedFactors: ['ip', 'user_agent', 'timezone'], + attributionMethod: 'fingerprint', + competingLinks: 2, + }); + }); + + it('maps deepLinkParameters to customParameters', () => { + const link = deferredDeepLinkFromInstall({ + ...matched, + deepLinkData: { + shortCode: 'abc123', + deepLinkParameters: { route: 'PRODUCT', id: '42' }, + } as unknown as DeepLinkData, + }); + + expect(link?.customParameters).toEqual({ route: 'PRODUCT', id: '42' }); + }); + + it('returns null for an organic install', () => { + const organic: InstallAttributionResponse = { + installId: 'install-2', + attributed: false, + confidenceScore: 0, + matchedFactors: [], + attributionMethod: 'none', + competingLinks: 0, + deepLinkData: {}, + }; + + expect(deferredDeepLinkFromInstall(organic)).toBeNull(); + }); +}); diff --git a/llms.txt b/llms.txt index bad0599..8fb74a5 100644 --- a/llms.txt +++ b/llms.txt @@ -60,6 +60,20 @@ interface DeepLinkData { appScheme?: string; clickedAt?: string; linkId?: string; + /** Deferred deep links only: attribution confidence score (0-100) */ + confidenceScore?: number; + /** Deferred deep links only: fingerprint factors that matched the click */ + matchedFactors?: string[]; + /** Deferred deep links only: 'fingerprint' when matched to a click (newer values may appear) */ + attributionMethod?: 'fingerprint' | 'none' | string; + /** + * Deferred deep links only: how many OTHER links had a click from the + * install's network that also reached the confidence threshold. 0 = no other + * link was in contention. Above 0 = another device's tap may be what led to + * this install; treat a sensitive link (sign-in, gift) as unverified. + * Undefined when the server does not report it. + */ + competingLinks?: number; } interface CreateLinkOptions { diff --git a/src/DeferredDeepLink.ts b/src/DeferredDeepLink.ts index 09effe9..f0d39c2 100644 --- a/src/DeferredDeepLink.ts +++ b/src/DeferredDeepLink.ts @@ -1,4 +1,38 @@ -import type { DeepLinkData, DeferredDeepLinkCallback } from './types'; +import type { + DeepLinkData, + DeferredDeepLinkCallback, + InstallAttributionResponse, +} from './types'; + +/** + * The deferred deep link carried by an install response, or null when the + * install is not attributed. + * + * `customParameters` is filled from the link's `deepLinkParameters`, so apps + * read the same field on deferred and direct deep links. The match certainty + * fields (`confidenceScore`, `matchedFactors`, `attributionMethod`, + * `competingLinks`) are taken from the deep link data, or from the top level of + * the response when the deep link data lacks them; a server that sends neither + * leaves them undefined. + */ +export function deferredDeepLinkFromInstall( + response: InstallAttributionResponse, +): DeepLinkData | null { + if (!response.attributed || !response.deepLinkData) return null; + + const link = response.deepLinkData as DeepLinkData & { + deepLinkParameters?: Record; + }; + + return { + ...link, + customParameters: link.deepLinkParameters || link.customParameters, + confidenceScore: link.confidenceScore ?? response.confidenceScore, + matchedFactors: link.matchedFactors ?? response.matchedFactors, + attributionMethod: link.attributionMethod ?? response.attributionMethod, + competingLinks: link.competingLinks ?? response.competingLinks, + }; +} /** * Delivers the deferred deep link once, on the launch that reports the install: diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 503a179..c21cb2e 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -8,7 +8,7 @@ import { DeepLinkHandler } from './DeepLinkHandler'; import { AttributionContext } from './AttributionContext'; import { NavigationTracker } from './NavigationTracker'; import { LinkDomains } from './LinkDomains'; -import { DeferredDeepLink } from './DeferredDeepLink'; +import { DeferredDeepLink, deferredDeepLinkFromInstall } from './DeferredDeepLink'; import { SDK_NAME, SDK_VERSION } from './version'; import type { LinkFortyConfig, @@ -509,16 +509,8 @@ export class LinkFortySDK { } // If attributed, store deep link data - if (response.attributed && response.deepLinkData) { - // Normalize backend response to match DeepLinkData type - // Populate customParameters from deepLinkParameters so app developers - // get a consistent field regardless of direct vs deferred deep link path - const deepLinkData: DeepLinkData = { - ...response.deepLinkData as DeepLinkData, - customParameters: (response.deepLinkData as any).deepLinkParameters - || (response.deepLinkData as DeepLinkData).customParameters, - }; - + const deepLinkData = deferredDeepLinkFromInstall(response); + if (deepLinkData) { await AsyncStorage.setItem( STORAGE_KEYS.INSTALL_DATA, JSON.stringify(deepLinkData) @@ -536,6 +528,7 @@ export class LinkFortySDK { if (this.config.debug) { console.log('[LinkForty] Install attributed with confidence:', response.confidenceScore); console.log('[LinkForty] Matched factors:', response.matchedFactors); + console.log('[LinkForty] Competing links:', deepLinkData.competingLinks); } } else { if (this.config.debug) { diff --git a/src/index.ts b/src/index.ts index 306f553..eabe811 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { DeviceFingerprint, DeepLinkData, InstallAttributionResponse, + AttributionMethod, EventData, ActiveAttribution, AttributionStamp, diff --git a/src/types.ts b/src/types.ts index 1f2e9dd..0a0520c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,8 +149,39 @@ export interface DeepLinkData { clickedAt?: string; /** Link ID */ linkId?: string; + /** + * Install attribution confidence score (0-100). Set on deferred deep links + * only; direct opens leave it undefined. + */ + confidenceScore?: number; + /** + * Fingerprint factors that matched the click (e.g. `['ip', 'user_agent', 'timezone']`). + * Set on deferred deep links only. + */ + matchedFactors?: string[]; + /** + * How the install was matched to the click. Set on deferred deep links only, + * and only by servers that report it. + */ + attributionMethod?: AttributionMethod; + /** + * How many other links had a click from the install's network that also + * reached the confidence threshold. 0 means no other link was in contention; + * above 0 means another device's tap may be what led to this install, so an + * app acting on a sensitive link (sign-in, gift) can treat the match as + * unverified and ask the user to confirm. Set on deferred deep links only, + * and only by servers that report it. + */ + competingLinks?: number; } +/** + * How an install was attributed: `'fingerprint'` when it was matched to a + * click, `'none'` for an organic install. Other values may be added by newer + * servers. + */ +export type AttributionMethod = 'fingerprint' | 'none' | (string & {}); + /** * Response from install attribution endpoint * Matches backend contract from /api/sdk/v1/install @@ -164,6 +195,18 @@ export interface InstallAttributionResponse { confidenceScore: number; /** Array of matched fingerprint factors (e.g., ['ip', 'user_agent', 'timezone', 'language', 'screen']) */ matchedFactors: string[]; + /** + * `'fingerprint'` when the install was matched to a click, `'none'` when it + * is organic. Undefined when the server does not report it. + */ + attributionMethod?: AttributionMethod; + /** + * How many other links had a click from the install's network that also + * reached the confidence threshold (0 when organic). Above 0 means another + * device's tap may be what led to this install. Undefined when the server + * does not report it. + */ + competingLinks?: number; /** Deep link data if attributed (contains shortCode, URLs, UTM params, confidence, etc.) */ deepLinkData: DeepLinkData | Record; }