Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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
Expand Down
138 changes: 136 additions & 2 deletions __tests__/DeferredDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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();
});
});
14 changes: 14 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 35 additions & 1 deletion src/DeferredDeepLink.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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:
Expand Down
15 changes: 4 additions & 11 deletions src/LinkFortySDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
DeviceFingerprint,
DeepLinkData,
InstallAttributionResponse,
AttributionMethod,
EventData,
ActiveAttribution,
AttributionStamp,
Expand Down
43 changes: 43 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, never>;
}
Expand Down
Loading