# Matching

Attribution answers one question: does this first app open belong to a click
Detour recorded a few minutes ago? The answer is either an exact match or a
score. This page describes how the server decides and which settings you can
change.

## How matching starts

On first app open the SDK calls the Detour match API. The server picks one of two
matching modes based on what the payload contains.

```mermaid
flowchart TD
    A[First app open] --> B{clickId present?}
    B -->|Yes| C[Deterministic lookup]
    B -->|No| E[Probabilistic scoring]
    C --> D{Click exists<br/>and unmatched?}
    E --> F[Load unmatched clicks<br/>in the time window]
    F --> G[Score candidates,<br/>keep the highest]
    G --> H{Score above<br/>the threshold?}
    D -->|Yes| M1[Return link URL]
    D -->|No| N1[Return 404]
    H -->|Yes| M2[Return link URL]
    H -->|No| N2[Return 404]
```

A returned link URL marks the install as non-organic. A 404 stores it as
organic. The default score threshold is 850.

## Matching modes

- **Deterministic (preferred)** — an exact `clickId` is present, from the Android install referrer. The server looks it up and returns the link if that click exists and is still unmatched. The lookup is exact.
- **Probabilistic (fallback)** — no `clickId`, which is the norm on iOS. Detour scores the device fingerprint sent by the SDK against recent unmatched click beacons.

## Probabilistic payload

The SDK sends these fields when there is no `clickId`:

| Field                                            | Type                       | Meaning                                                   |
| ------------------------------------------------ | -------------------------- | --------------------------------------------------------- |
| `platform`, `model`, `manufacturer`, `systemVersion` | `string`               | Device signature                                          |
| `screenWidth`, `screenHeight`, `scale`           | `number`                   | Screen size and pixel ratio                               |
| `locale`                                         | `{ languageTag: string }[]` | Device languages. `languageTag` is scored.               |
| `timezone`                                       | `string \| null`           | Device timezone                                           |
| `userAgent`                                      | `string`                   | Raw user agent                                            |
| `timestamp`                                      | `number`                   | Capture time in Unix milliseconds, checked against the match window |
| `pastedLink`                                     | `string`, optional         | iOS only. Clipboard content, compared with the click URL and its token |

:::note[Clipboard is an iOS-only signal]
Every SDK sends `pastedLink` as `null` on Android. Reading the clipboard there raises a system toast and is restricted to foreground apps, so the Android and Flutter SDKs never attempt it and the React Native SDK gates the read on `Platform.OS === "ios"`. An Android probabilistic match is therefore scored without the 350-point pasteboard signal, so the Play Install Referrer is the main deterministic signal on Android.
:::

## Scoring weights

The server scores each candidate click using separate scoring functions. The weights used by Detour today:

| Signal                              | Weight | Scored on     |
| ----------------------------------- | -----: | ------------- |
| IP exact                            |    500 | iOS + Android |
| Model + system version exact        |    450 | Android <sup>\*</sup> |
| iOS system version match            |    350 | iOS           |
| User-agent device signature match   |    350 | Android       |
| Pasteboard URL/token match          |    350 | iOS           |
| Timezone                            |    200 | iOS + Android |
| Screen match                        |    200 | iOS + Android |
| Language match                      |    100 | iOS + Android |

<div class="table-footnote">
  <sup>*</sup>The model and system version come from high-entropy client hints.
  The check itself is platform-agnostic, but WebKit does not implement those
  hints, so only Android browsers supply the values.
</div>

The highest weights go to the signals that are hardest to spoof or to match by
coincidence: IP, device signature, and clipboard. Weaker signals such as language
add only a small score.

Rules that change the totals at runtime:

- Only one device signal is scored per candidate.

  `Model + system version` is used when the browser supplies both. On iOS it
  never does, so the system version parsed from the click's user agent is scored
  instead. The user-agent device signature is the fallback everywhere else. The
  highest reachable total is therefore **1700 on iOS** and **1450 on Android**.
- Screen comparison allows a tolerance of `±1` on width and height, `±0.01` on scale.
- The iOS pasteboard score has two tiers:
  - `350` when the token matches and the pasted URL starts with the click URL.
  - `175` when only the URL prefix matches.

## Threshold and time window

Both values are app-level settings from `Link settings → Matching configuration`:

- **Matching threshold** — default **850**, allowed range `700..1200`
- **Matching time window** — default **15 minutes**, allowed range `5..180` minutes

:::note[Customizing the threshold]
The default of **850** balances accuracy and recall. Change it when you need a
different trade-off:

- **Lower** (for example 700) finds more matches but attributes more installs to
  the wrong link. Usually acceptable for general marketing links.
- **Higher** (for example 1000) is stricter and misses some legitimate matches.
  Prefer it when links point at sensitive content.
:::

Clicks outside the window are ignored. The window is measured between the click timestamp and the fingerprint timestamp. The default of **15 minutes** covers a normal install and limits the number of false positives.

:::note[Customizing the time window]
The default of **15 minutes** is configurable.

- **Shorter** (5–10 minutes) raises confidence, because the click and the first open
  are close in time. Slow downloads then fall outside the window and are not
  matched.
- **Longer** (30–60 minutes) matches users who were interrupted during the install, but
  it enlarges the candidate pool and raises the false-positive rate.
:::

## Candidate selection

In probabilistic mode the server:

1. Loads the app's unmatched clicks inside the configured time window.
2. Scores every candidate.
3. Keeps the highest score.
4. Breaks ties in favor of the newer click.
5. Accepts the highest-scoring candidate only if its score is `>= threshold`.

## After a match

A successful match does three things:

- marks the click as matched,
- stores the install as non-organic in the analytics install stream,
- returns the resolved link URL.

With no match, the install is recorded as organic and the API returns `404`.

## Example scores

- **iOS — IP + exact pasteboard token and URL** — `500 + 350 = 850`. Accepted at the default threshold.
- **Android — IP + user-agent device signature** — `500 + 350 = 850`. Accepted at the default threshold.
- **Timezone + language + screen** — `200 + 100 + 200 = 500`. Rejected.

## Tuning

Start with the defaults and change them only after you have seen real traffic in
analytics. If too many matches are missed, lower the threshold or extend the time
window. If you see cross-attribution in dense traffic, raise the threshold or
shorten the window.

For practical edge cases that affect tuning decisions, see [Limitations & Known Issues](https://detour.swmansion.com/docs/platform/architecture/architecture-limitations).

## Related pages

[Click Handling & Redirect Flow](https://detour.swmansion.com/docs/platform/architecture/click-handling/)
  [Limitations & Known Issues](https://detour.swmansion.com/docs/platform/architecture/architecture-limitations/)
  [Analytics](https://detour.swmansion.com/docs/platform/fundamentals/analytics/)
  [Getting Started with Detour](https://detour.swmansion.com/docs/platform/fundamentals/getting-started/)