Developer docs

Deferred deep linking for React Native and Expo. Install the SDK, point it at a link domain, and read one hook on first launch. Everything below is the whole surface — there is not a second half.

Quickstart

Five minutes, four steps. You need an app and an API key from the dashboard first — create one free.

1. Install

@react-native-async-storage/async-storage is a peer dependency: it is where the resolved link and the “already attempted” flag live.

$ npx expo install @uselinking/react-native
$ npx expo install @react-native-async-storage/async-storage

2. Add the config plugin

The plugin writes the two pieces of native config everyone gets wrong: the iOS associated-domains entitlement and the Android App Links intent filter. linkDomain is a bare host — no scheme, no path.

app.json
{
  "expo": {
    "plugins": [
      ["@uselinking/react-native", { "linkDomain": "acme.lnk.uselinking.com" }]
    ]
  }
}
$ npx expo prebuild
 ios     applinks:acme.lnk.uselinking.com added to the entitlements
 android https intent filter added to the main activity

Associated-domains changes are native config. They need a new build — an OTA update will not pick them up.

3. Mount the provider

Resolution starts on mount. The state itself is a module singleton, so the provider carries no context — it exists to mark where resolution begins.

App.tsx
import { DeepLinkProvider } from '@uselinking/react-native'

export default function App() {
  return (
    <DeepLinkProvider
      config={{
        apiKey: process.env.EXPO_PUBLIC_USELINKING_KEY,
        endpoint: 'https://api.uselinking.com',
      }}
    >
      <Root />
    </DeepLinkProvider>
  )
}

4. Read the link

Root.tsx
import { useDeferredLink } from '@uselinking/react-native'

function Root() {
  const { isLinkProcessed, link, clearLink } = useDeferredLink()

  // Hold your splash until resolution settles, then route once.
  if (!isLinkProcessed) return <Splash />

  if (link?.matchType === 'unique') {
    return <Navigator initial={link.path} params={link.params} onDone={clearLink} />
  }
  return <Onboarding />
}

Configuration

interface Config {
  apiKey: string          // dashboard-issued key, "dlk_live_…"
  appId?: string          // reserved; not sent on the wire yet
  endpoint?: string       // API origin. Override for self-hosting and local dev
  matchTimeoutMs?: number // per-request budget for /v1/match. Default 5000
}

apiKey is issued per app in the dashboard and is safe to ship in your bundle — it only authorises /v1/match for that one app, and a match returns nothing unless the caller is the device that produced a pending click. Revoking a key in the dashboard takes effect on the next request.

endpoint has a built-in default, but pin it explicitly: it is the one setting that differs between your production build, a self-hosted deployment and a simulator pointed at a dev server.

SDK reference

The React binding is two exports — DeepLinkProvider and useDeferredLink(), which returns { isLinkProcessed, link, clearLink }. Everything is also reachable imperatively for apps that resolve links outside React.

import {
  init, getInitialLink, waitForInitialLink, clearLink, subscribe, getSnapshot,
} from '@uselinking/react-native'

await init(config)              // idempotent — the first call wins
await waitForInitialLink(3000)  // startup gate; never rejects, never blocks past the timeout
const link = await getInitialLink()

const unsubscribe = subscribe(() => render(getSnapshot()))
await clearLink()               // drop the link once handled; it never comes back

waitForInitialLink(timeoutMs = 5000) is the startup gate: it resolves when the link settles — matched, empty or failed — or when the timeout elapses, whichever lands first. It never rejects, so it can sit in front of your splash screen without a try/catch.

Lower-level pieces are exported too — buildMatchBody(), postMatch(), MatchRejectedError, DEFAULT_ENDPOINT and SDK_VERSION — if you want the wire call without the state machine.

Match confidence

Every match is labelled. This is the part of the API worth reading twice, because it is where a deferred-link product either tells you the truth or quietly guesses.

interface DeferredLink {
  url: string                     // the short URL that was clicked
  path: string                    // in-app path to route to, e.g. '/invite/9fb2'
  params: Record<string, string>  // params frozen at click time
  matchType: 'unique' | 'weak'    // 'none' surfaces as link === null
  clickId: string                 // server-side click id, for support
}
  • unique — exactly one pending click survived strict filtering, or Android handed back an exact install referrer. Route the user.
  • weak — strict filtering eliminated everything, but only one click was pending at all. Probably right. Your call: route it, or show a “continue where you left off?” confirmation.
  • none — nothing to claim. link is null and your app runs its normal onboarding.

A click is consumed once, via a compare-and-swap on the click row. If two first launches race the same pending click, exactly one wins and the other gets none. A link is never delivered twice.

Resolution lifecycle

One deferred link per install, resolved once, observable from anywhere.

  • First launch — exactly one match attempt. The response is persisted before it is delivered to your code, so an app that dies mid-launch still has the link next time. The server already consumed the click; re-asking would return none.
  • Network or API failure — the SDK settles with link === null so your app proceeds, but does not record the attempt. The next launch tries again.
  • Later launches — resolved from storage immediately. No network call.
  • After clearLink() — the link is gone from memory and storage, and the install stays marked as attempted. A cleared link never comes back.

Transport failures retry twice, at 500 ms and 1500 ms, for network errors and 5xx only. A 4xx means the request itself is wrong — bad key, bad body — and throws MatchRejectedError without retrying.

Platform behaviour

Android — deterministic

The store redirect appends referrer=c=<clickId>&l=<linkCode> to the Play Store URL. After install, the app reads it back verbatim through the Play Install Referrer API and the server resolves it by identity — no fingerprinting, no inference. Android clicks are held for 48 hours, and the referrer path is idempotent, so a relaunch after a crash still gets its link.

iOS — probabilistic

The App Store passes nothing through. The server matches the first launch against pending clicks on IP bucket, OS major version, device class and locale, inside the app's click TTL (default 20 minutes, configurable 60–3600 seconds), and requires a single surviving candidate. At most 20 candidates are considered per request.

Known limits, stated plainly: carrier-grade NAT puts many users behind one IP, iCloud Private Relay means the Safari click IP is not the app IP, and switching between WiFi and cellular between click and install breaks the bucket. Safari's user agent is coarse — no device model, frozen OS version. Expect 70–90% in practice, which is the same band the incumbents operate in.

Ambiguity always resolves to none. Sending a user into someone else's invite is a much worse failure than sending them to your home screen.

HTTP API

The SDK is a thin client over one endpoint. Call it directly if you are on a platform we do not ship an SDK for.

POST/v1/match

Authenticate with Authorization: Bearer <apiKey>. The server reads the client IP from the request itself — do not send it.

$ curl -X POST https://api.uselinking.com/v1/match \
    -H 'authorization: Bearer dlk_live_…' \
    -H 'content-type: application/json' \
    -d '{
      "platform": "ios",
      "osVersion": "18.4",
      "deviceModel": "iPhone16,2",
      "locale": "en-GB",
      "timezone": "Europe/London",
      "screen": { "width": 430, "height": 932, "scale": 3 },
      "sdkVersion": "0.0.1"
    }'

platform, osVersion, deviceModel, locale, timezone and sdkVersion are required; screen is optional, and installReferrer is Android-only.

200 OK
{
  "matchType": "unique",
  "link": {
    "url": "https://acme.lnk.uselinking.com/Ab3xYz9k",
    "path": "/invite/9fb2",
    "params": { "it": "ey…" },
    "clickId": "3f0c9e2a-…"
  }
}

matchType is unique, weak or none; link is null exactly when the type is none. A bad or revoked key returns 401; a body that fails validation returns 400 with the offending issues.

Domain association

Universal Links and App Links assets are served for you on your link domain, built from what you configured in the dashboard:

  • /.well-known/apple-app-site-association — needs the iOS bundle ID and team ID. The legacy root path is served too, because Apple still probes it.
  • /.well-known/assetlinks.json — needs the Android package name and at least one signing-certificate SHA-256 fingerprint.

Until those fields are filled in, the corresponding file 404s and the OS will not associate your app with the domain — links will open in the browser instead. The app's detail page in the dashboard shows the live status of both.

Testing & debugging

Deferred links are awkward to test because “first launch” is a once-per-install event. Two dev-only hooks make it repeatable.

import { _resetFirstLaunch, _setDebugInstallReferrer, init } from '@uselinking/react-native'

// Forget that a match was ever attempted — the next init() is a true first launch.
await _resetFirstLaunch()
await init(config)

// Android without a real Play install: stand in for the Install Referrer API.
_setDebugInstallReferrer('c=<clickId>&l=<linkCode>')

Both are underscore-prefixed for a reason: they exist for development and E2E runs. Do not ship a build that calls them on a user's device.

Pointing at a local API

// iOS Simulator shares the Mac's network:
endpoint: 'http://localhost:3000'
// Android emulator reaches the host through a fixed alias:
endpoint: 'http://10.0.2.2:3000'

On iOS, uninstalling the app clears its storage, so a reinstall is a genuine first launch. On Android, use _setDebugInstallReferrer() to stand in for a real Play install, which is the only way to get a genuine referrer.

Migrating from Firebase Dynamic Links

FDL shut down in August 2025. If you are still carrying its stub, the shape you are replacing is small, and so is the replacement.

  1. Create an app in the dashboard and pick a link subdomain. This replaces your page.link domain.
  2. Re-create your dynamic links as links here: the FDL link parameter becomes deepPath plus params, and ofl/afl become fallbackUrl.
  3. Swap the SDK. getInitialLink() is the direct analogue of getDynamicLink(), except it also tells you how confident the match was.
  4. Drop the FDL config plugin and run npx expo prebuild so the entitlement and intent filter point at the new domain.

The behavioural difference worth planning for: FDL returned a link or nothing. Here you also get weak, and deciding what your app does with a probable-but-unproven match is a product decision, not a default we will make for you.