Self-hosted deferred deep linking and referral attribution for React Native. An open-source replacement for the deferred-linking half of Firebase Dynamic Links — Android Install Referrer, iOS clipboard handoff, and fingerprint matching fallback, with a PHP or Node backend.
See the codeA deferred deep linking referral system you fork and self-host: four packages — two interchangeable backends, plus web and mobile — and runnable examples. In production at Sparkle, a Nigerian microfinance bank. New here? Read this file top to bottom — it's written as a guided path, not a reference dump: what this is, how it works, how to run it, and where to go next depending on what you're trying to do.
A referral link tapped on a real iPhone, on cellular, installing from the App Store — and the code arriving in the signup field on the other side. This isn't the demo app or a staged build: it's Sparkle's live production release, a Nigerian microfinance bank, running this SDK.
https://github.com/user-attachments/assets/df1bc936-46cc-4e6a-8680-3d0fee641158
Thirty-five seconds, one take, nothing sped up or cut:
| 0:00 | A referral link, sparkle.ng/referral/L82WDL |
| 0:06 | The landing page registers the click and starts its countdown |
| 0:10 | App Store — a real download, not a prepared build |
| 0:26 | First launch, onboarding |
| 0:32 | Referral screen: the field is empty, with a Paste button |
| 0:33 | L82WDL is in the field. Paste becomes Continue |
That half-second is the entire point of the iOS deterministic tier. The code was written to the clipboard by the landing page moments before the App Store redirect, survived the install, and came back without a network call, a fingerprint guess, or the user typing anything.
Two details worth watching for, because both are deliberate:
UIPasteControl
doing its job — the user's tap authorises the read, so iOS doesn't have
to warn them about it. See docs/decisions.md #18
for why this tier is a tap rather than an automatic read.This is the iOS path, which is the harder one to believe — it depends on a clipboard payload surviving Safari, the App Store and an install. Android is the easy case and needs no video: the Play Install Referrer hands the code over before the app's first frame.
Want to drive it yourself rather than watch? Quick start has a live web demo and an Android APK.
packages/
referral-sdk/ PHP / Composer — backend: click store, matching, claims
referral-sdk-node/ @blynk-deferlink/referral-sdk-node — Node.js backend (Express + Postgres), same API contract
referral-web/ @blynk-deferlink/referral-web — React landing page + hooks
referral-mobile/ @blynk-deferlink/referral-mobile — React Native code recovery
examples/
mock-backend/ zero-dependency Node stand-in for either real backend (in-memory, not for production)
web/ Vite React app using @blynk-deferlink/referral-web
mobile/ Expo app using @blynk-deferlink/referral-mobile
share link tapped → landing page stores a click, signs a token
→ user installs app → app recovers the code + token, one of two ways:
deterministic (no network call, no guessing):
Android reads it off the Play Install Referrer, automatically
iOS reads it off the clipboard, only if <ReferralPasteButton> is tapped
probabilistic (fallback, whichever platform didn't get a deterministic hit):
a scored fingerprint match against recent unmatched clicks
→ code + token pre-fill signup → claim verifies the token,
records the conversion, distributes the reward
Deterministic and probabilistic recovery are two separate mechanisms, not two branches of one path — shown below as two diagrams, both starting at the same click and ending at the same claim.
Android's default path, and iOS's opt-in path — both read a code and proof entirely on-device, no network call. Neither touches fingerprint matching at all.
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Share link tapped"] --> B["Landing page (referral-web)<br/>POST /click"]
B --> C["Backend stores click, signs token<br/>click_id.expiry.hmac"]
C --> D1[Android]
C --> D2[iOS]
subgraph android [ANDROID]
D1 --> E1["Token embedded in Play Store referrer param"]
E1 --> F1["App installed"]
F1 --> G1{"Install Referrer has<br/>code + token?"}
G1 -->|yes| H1["<b>method: install_referrer</b><br/>read fully locally, no network call"]
G1 -.->|"no"| X1["see Probabilistic diagram"]
end
subgraph ios [iOS]
D2 --> E2["Token + code written to clipboard,<br/>right before store redirect"]
E2 --> F2["App installed"]
F2 --> G2{"User taps<br/><ReferralPasteButton>?"}
G2 -->|yes| H2["<b>method: clipboard</b><br/>read fully locally, no network call<br/>overrides an automatic match, if tapped"]
G2 -.->|"not tapped (default)"| X2["see Probabilistic diagram"]
end
H1 --> P["code + token ready"]
H2 --> P
P --> Q["Pre-fills signup<br/>POST /claim {device_id, token, method}"]
Q --> R["Verified → conversion recorded<br/>reward distributed"]
classDef ghost fill:transparent,stroke-dasharray: 2 3,color:#888
class X1,X2 ghost
Reached only when the deterministic path above didn't run or didn't have anything to read — a scored guess, backed by the same signed token once it succeeds. Both platforms feed the same scoring engine; this is the one piece of matching logic that isn't platform-specific at all.
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Share link tapped"] --> B["Landing page (referral-web)<br/>POST /click"]
B --> C["Backend stores click, signs token<br/>click_id.expiry.hmac"]
C --> D1[Android]
C --> D2[iOS]
subgraph android [ANDROID]
D1 --> E1["Install Referrer empty or sideload<br/><i>fallback only — see Deterministic</i>"]
end
subgraph ios [iOS]
D2 --> E2["Automatic, every launch, no gesture required<br/><i>iOS's default recovery attempt</i>"]
end
E1 --> M["<b>Match Engine</b><br/>scores fingerprint vs. recent unmatched clicks<br/>IP · device · screen · timezone · language · recency"]
E2 --> M
M --> N{"score ≥ min_confidence<br/>(default 70)?"}
N -->|yes| O1["<b>method: fingerprint</b><br/>click locked to device, atomically"]
N -.->|no| O2["No match<br/>code: null — manual entry fallback"]
O1 --> P["code + token ready<br/>(or a manual code, no token, on no match)"]
O2 -.-> P
P --> Q["Pre-fills signup<br/>POST /claim {device_id, token, method}"]
Q --> R["Verified → conversion recorded<br/>reward distributed"]
classDef nomatch stroke-dasharray: 2 3
class O2 nomatch
Reliability: Android deterministic recovery hits ~100% of real installs; iOS deterministic recovery depends entirely on whether the paste button is rendered and tapped. Probabilistic matching sees an observed ~85–90% match rate on iOS, where it's the default fallback; lower priority on Android, where it's a fallback of last resort.
Treat that iOS figure as what a single self-hosted deployment sees, and don't compare it directly against a commercial MMP's published numbers. Branch and AppsFlyer score probabilistic matches against signal pooled across their whole SDK install base — many apps, shared IP graphs — while this only ever sees your own clicks. That's a structural difference in available signal, not a tuning gap you can close by adjusting weights.
The proof that makes both deterministic paths possible: every
recovered code carries a signed proof (click_id.expiry.hmac), minted
once at /click — this is what lets both deterministic paths stay
genuinely network-free at recovery time, and what /claim verifies before
any reward is paid out. A code with no valid token behind it (including
one a user typed in by hand) can reach signup, but can never clear
/claim — see docs/decisions.md #21/#22 for the
full reasoning behind why this replaced an earlier redeem-round-trip
design.
Read the scope line first, because it's the part that decides whether this is for you: Branch and AppsFlyer are mobile measurement platforms. blynk-deferlink is the deferred-deep-linking and referral-attribution slice of what they do, self-hosted. If you need ad-network attribution, this is not a replacement.
| blynk-deferlink | Branch / AppsFlyer | |
|---|---|---|
| Hosting | your infrastructure | SaaS |
| Click and conversion data | your database | their cloud |
| Cost | your hosting bill | see below |
| Source | MIT, forkable | closed |
| Vendor lock-in | none — it's your schema | re-instrumentation to leave |
| Third-party data sharing | none | governed by their terms |
Worth being blunt about, because it's most of what the money buys:
apple-app-site-association and assetlinks.json files and handles
domain verification for you; here that's yours to configure. The landing
page does attempt to open an already-installed app via its custom URL
scheme, but that attempt is deliberately fired into a hidden iframe and
is effectively inert — no custom-scheme technique is both silent when the
app is absent and functional when it's present. Only Universal Links and
App Links solve that properly. See
docs/decisions.md #27 for the full reasoning.
Deferred deep linking — the actual point of this project — works
regardless, because it runs after install rather than at link-tap.referral_clicks table can cross-match: fingerprint matching considers
every recent click, and the five non-recency signals (IP, device model,
screen, timezone, language) sum to 85 against a default threshold of 70.
Two apps aimed at the same user base — a consumer app and a companion
app, say — clear that bar on ordinary household overlap rather than by
coincidence, and the second app's user gets attributed the first app's
referrer. Run a separate deployment and database per app until app
scoping lands; see docs/decisions.md #32 for the
analysis and the planned fix.docs/decisions.md.When to use them instead: you're running paid acquisition and need MMP attribution; you want fraud protection you don't have to build; or you'd rather buy a supported product than operate one. Those are good reasons, and this project doesn't pretend otherwise.
When to use this: referral and invite flows are the attribution you care about, you want the data in your own database, or a per-MAU bill doesn't make sense for your margins.
FDL was deprecated in May 2023 with no official replacement and shut down completely on 25 August 2025 — every link stopped resolving, deferred linking included. If that's what brought you here, this is probably a closer fit than Branch is.
FDL was never an MMP. It did link hosting, Universal/App Links handling, deferred deep linking, and basic click analytics — and nothing else. No ad-network attribution, no fraud detection, no campaign analytics. So the honest mapping is:
| FDL did | here |
|---|---|
| Deferred deep linking | ✅ the core of this project |
| Basic click tracking | ✅ every click is a row in your database |
Link hosting (page.link) | ❌ your own domain, your own routes |
| Universal Links / App Links setup | ❌ yours to configure |
In other words: this covers the deferred-attribution half of FDL, not the link-hosting half. That's the honest scope. If all you needed was the deferred part — which for referral flows is usually the case — the gap is smaller than it looks, and you get to keep the data.
Running in production at Sparkle, a Nigerian microfinance bank, on the PHP backend — real referral traffic, real conversions. A second Sparkle app is rolling out on it now. The Node backend serves this project's own live demo.
See it live in production: https://sparkle.ng/referral/L82WDL — a real Sparkle referral link, served by this SDK. Open it on a phone to watch the actual flow: the landing page registers a click, hands the code off, and the app recovers it after install. (It's the maintainer's own referral link — Sparkle only credits a referral once a referred user completes a first transaction, so opening it to watch the mechanics does nothing. It's here because a real production link demonstrates more than a staged one.)
One thing worth knowing: coverage is uneven by package. The scoring
engine, click tokens and conversion tracking are well covered on both
backends; referral-web's test suite is new and still thin. CI runs every
suite on each PR across both Node and PHP version ranges.
docs/decisions.md is the honest record of how this was built — including
the bugs found by actually running it on real devices, and the trade-offs
accepted rather than solved. It's the fastest way to judge whether the
engineering here meets your bar.
Don't want to clone anything yet? Try the recovery flow live — pick a referral code and it builds a real link to the actual production landing page (real countdown, real click registration, real clipboard handoff), then recovers it exactly how a real installed app would (Android: automatic via the Play referrer param; iOS: a real clipboard check, falling back to a real fingerprint match), with the actual request/response for every step.
Want the real mobile app, not just the web half? Download the Android demo APK — open that link on an Android device and install it; no Play Store listing and no Expo account needed. You'll have to allow installs from your browser the first time, since it's unlisted rather than Play-distributed. It's served from a GitHub release deliberately: the EAS build link this used to point at expires after 30 days, and did — a release asset doesn't. iOS has no equivalent link — internal distribution there needs a registered Apple Developer account and per-device UDID registration, so for now the iOS side is only reachable by building it yourself (see below), or by watching it run in production in See it working above.
Otherwise, the fastest way to see the whole thing work locally, before installing anything for real. Three terminals. No PHP or database required — the mock backend covers it.
# 1) backend (http://localhost:8787)
node examples/mock-backend/server.js
# 2) web landing page (http://localhost:5173/?code=1234)
npm install
npm --workspace examples/web run dev
# 3) mobile app
npm --workspace examples/mobile run start
Open the web page with ?code=1234 and watch the backend log the click. On
desktop it shows both store buttons; on a phone browser it attempts the app
handoff then redirects to the store.
The mobile app recovers automatically on launch, same as a real app — there's nothing to simulate inside it. To see it actually find something, type a code into its own "Generate a referral link" card and tap Open link — that opens the real landing page in your device/simulator's browser (real countdown, real click registration, real clipboard handoff), then switch back to the app to see the recovery.
expo run:ios /
expo run:android), not Expo Go, because react-native-device-info and
react-native-play-install-referrer include native code. (The SDK itself
persists nothing to disk — no AsyncStorage or other storage dependency at
all; see packages/referral-mobile/README.md.)localhost won't reach your machine — set API in
examples/mobile/App.tsx to your computer's LAN IP.react-native-play-install-referrer is required for Android, not
optional — Android's deterministic recovery path depends on it, and
useReferralCode() throws a clear error if it's missing rather than
silently falling back to the weaker fingerprint-matching path. (This SDK
previously referenced a package called react-native-android-install-referrer,
which turned out to not exist on npm at all — see
packages/referral-mobile/README.md.)<ReferralLanding> always
tries myapp://referral?code=... first, before falling through to the
countdown/store redirect — confirmed with xcrun simctl openurl against a
real installed build. On a real production build this routes straight
into the app, no dialog. On an Expo development build specifically
(what expo run:ios/expo run:android produce), Expo's own dev-client
intercepts every custom-scheme link first with a "which dev server should
open this?" picker (it supports pointing one binary at several Metro
instances) — pick this app's entry there and it proceeds normally.
Standard Expo tooling behavior, not something this SDK or example
controls.Once the demo makes sense, this is where to go depending on what you're actually trying to do:
Setting this up for real — pick the piece you need:
| Guide | What it covers |
|---|---|
docs/integration/referral-sdk.md | PHP backend — Laravel or standalone, zero to a verified /click → /claim round trip |
docs/integration/referral-sdk-node.md | Node backend — local run, Postgres setup, Vercel deploy |
docs/integration/referral-web.md | Landing page — provider config, rendering <ReferralLanding>, verifying a click registers |
docs/integration/referral-mobile.md | Mobile app — install, wiring useReferralCode(), on-device verification |
docs/integration/README.md | Index — what order to read the above in |
The four recovery paths (deterministic/probabilistic × Android/iOS), diagrammed, live in How it works above rather than a separate doc — it's central enough to the project to read before anything else, not something to click away for.
Understanding why it's built this way, not just how to use it:
docs/decisions.md — the engineering-decisions log.
Every non-obvious choice in this codebase (why matching uses a graduated
recency score, why /claim requires a signed token instead of trusting the
request, why the mobile SDK persists nothing to disk) is written up there
with the problem it solved, in chronological order.
API/config reference for a package you've already set up lives in that
package's own README (packages/*/README.md) — the integration guides above
are the walkthrough; the READMEs are what to come back to for a specific
config field or endpoint shape.
Fork it and run it as your own. That's the intended model, not a temporary state of affairs. Attribution data is the thing you least want sitting behind someone else's API: every click, every device fingerprint, every conversion. Self-hosting means the click table lives in your database, the matching runs on your server, and nobody can reprice, rate-limit, sunset, or read it. Firebase Dynamic Links shut down in August 2025 and took every link with it; that risk is the whole reason this exists, and shipping it as a hosted dependency would just recreate it.
So there's no registry install. Fork or clone, point the backend at your database, and change whatever doesn't fit — the code is yours at that point, and the API contract is small enough to modify without fear (two independent backends implement it, which is the proof it's not tangled).
The full reasoning, including what this costs you — slower to adopt than
npm install, and forks get no automatic security updates — is
decision #31.
This is how it runs in production today. Sparkle, a
Nigerian microfinance bank, runs its referral programme on it: the PHP
backend SDK deployed on their own infrastructure, against their own
database and their own reward logic. Nothing in this repo is a staging
copy of that — it's the same code path, which is why the bugs in
docs/decisions.md were found on real devices rather
than in tests.
The packages are consumed from source. Three ways, depending on how your project is laid out:
1. Workspaces (what this repo uses). From the repo root:
npm install # links packages/* and installs example deps
The examples already declare the SDKs as file: dependencies, so they resolve
to the local source automatically.
2. A file: dependency in your own project.
// package.json
"dependencies": {
"@blynk-deferlink/referral-mobile": "file:/path/to/packages/referral-mobile"
}
React Native's Metro bundles the SDK straight from its TypeScript source (the
package's react-native field points at src/), so no build step is needed for
the mobile SDK. The web SDK is consumed from source here via a Vite alias; for a
non-source consumer, build it first (below).
3. A tarball. Build, pack, then install the .tgz anywhere:
npm --workspace @blynk-deferlink/referral-web run build
npm --workspace @blynk-deferlink/referral-web pack # → blynk-deferlink-referral-web-1.0.0.tgz
npm install ./blynk-deferlink-referral-web-1.0.0.tgz
Publishing your fork privately. If your team would rather consume these
as ordinary dependencies than as source, publish them to your own registry
under your own scope: npm run build:sdks, then npm publish in each
package, and composer / Packagist for the PHP one. Renaming the scope in
each package.json is the only change required.
packages/referral-sdk (PHP)
or packages/referral-sdk-node (Node/Express + Postgres, deployable to
Vercel or any Node host). Same API contract; the web/mobile SDKs don't care
which one is behind apiEndpoint. See the Documentation
section above for step-by-step setup guides for all four packages.code_validator) and reward distribution
(on_claim_callback) in whichever backend config you use.buildReferralMeta.Contributions are genuinely welcome, and the project is set up so that a first one is easy to land.
CONTRIBUTING.md covers setup, how to run each
package's tests, and what to verify before opening a PR. CI runs every
suite on each PR across both Node and PHP version ranges, so you get
automated feedback without waiting on a review.
Good first issues are labelled
good first issue
— currently small, self-contained test-coverage tasks in the web SDK, each
one a single file you can read in a couple of minutes.
Two things worth knowing before you start:
docs/decisions.md is the map. Every non-obvious
choice in this codebase has a numbered entry explaining the problem, the
decision, and what was actually built. If something looks wrong, check
there first — it's often deliberate, and the entry will say why.135 commits
1 commits
TypeScript
58.3%
PHP
38.7%
Swift
2.1%
Self-hosted deferred deep linking and referral attribution for React Native. An open-source replacement for the deferred-linking half of Firebase Dynamic Links — Android Install Referrer, iOS clipboard handoff, and fingerprint matching fallback, with a PHP or Node backend.
See the codeA deferred deep linking referral system you fork and self-host: four packages — two interchangeable backends, plus web and mobile — and runnable examples. In production at Sparkle, a Nigerian microfinance bank. New here? Read this file top to bottom — it's written as a guided path, not a reference dump: what this is, how it works, how to run it, and where to go next depending on what you're trying to do.
A referral link tapped on a real iPhone, on cellular, installing from the App Store — and the code arriving in the signup field on the other side. This isn't the demo app or a staged build: it's Sparkle's live production release, a Nigerian microfinance bank, running this SDK.
https://github.com/user-attachments/assets/df1bc936-46cc-4e6a-8680-3d0fee641158
Thirty-five seconds, one take, nothing sped up or cut:
| 0:00 | A referral link, sparkle.ng/referral/L82WDL |
| 0:06 | The landing page registers the click and starts its countdown |
| 0:10 | App Store — a real download, not a prepared build |
| 0:26 | First launch, onboarding |
| 0:32 | Referral screen: the field is empty, with a Paste button |
| 0:33 | L82WDL is in the field. Paste becomes Continue |
That half-second is the entire point of the iOS deterministic tier. The code was written to the clipboard by the landing page moments before the App Store redirect, survived the install, and came back without a network call, a fingerprint guess, or the user typing anything.
Two details worth watching for, because both are deliberate:
UIPasteControl
doing its job — the user's tap authorises the read, so iOS doesn't have
to warn them about it. See docs/decisions.md #18
for why this tier is a tap rather than an automatic read.This is the iOS path, which is the harder one to believe — it depends on a clipboard payload surviving Safari, the App Store and an install. Android is the easy case and needs no video: the Play Install Referrer hands the code over before the app's first frame.
Want to drive it yourself rather than watch? Quick start has a live web demo and an Android APK.
packages/
referral-sdk/ PHP / Composer — backend: click store, matching, claims
referral-sdk-node/ @blynk-deferlink/referral-sdk-node — Node.js backend (Express + Postgres), same API contract
referral-web/ @blynk-deferlink/referral-web — React landing page + hooks
referral-mobile/ @blynk-deferlink/referral-mobile — React Native code recovery
examples/
mock-backend/ zero-dependency Node stand-in for either real backend (in-memory, not for production)
web/ Vite React app using @blynk-deferlink/referral-web
mobile/ Expo app using @blynk-deferlink/referral-mobile
share link tapped → landing page stores a click, signs a token
→ user installs app → app recovers the code + token, one of two ways:
deterministic (no network call, no guessing):
Android reads it off the Play Install Referrer, automatically
iOS reads it off the clipboard, only if <ReferralPasteButton> is tapped
probabilistic (fallback, whichever platform didn't get a deterministic hit):
a scored fingerprint match against recent unmatched clicks
→ code + token pre-fill signup → claim verifies the token,
records the conversion, distributes the reward
Deterministic and probabilistic recovery are two separate mechanisms, not two branches of one path — shown below as two diagrams, both starting at the same click and ending at the same claim.
Android's default path, and iOS's opt-in path — both read a code and proof entirely on-device, no network call. Neither touches fingerprint matching at all.
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Share link tapped"] --> B["Landing page (referral-web)<br/>POST /click"]
B --> C["Backend stores click, signs token<br/>click_id.expiry.hmac"]
C --> D1[Android]
C --> D2[iOS]
subgraph android [ANDROID]
D1 --> E1["Token embedded in Play Store referrer param"]
E1 --> F1["App installed"]
F1 --> G1{"Install Referrer has<br/>code + token?"}
G1 -->|yes| H1["<b>method: install_referrer</b><br/>read fully locally, no network call"]
G1 -.->|"no"| X1["see Probabilistic diagram"]
end
subgraph ios [iOS]
D2 --> E2["Token + code written to clipboard,<br/>right before store redirect"]
E2 --> F2["App installed"]
F2 --> G2{"User taps<br/><ReferralPasteButton>?"}
G2 -->|yes| H2["<b>method: clipboard</b><br/>read fully locally, no network call<br/>overrides an automatic match, if tapped"]
G2 -.->|"not tapped (default)"| X2["see Probabilistic diagram"]
end
H1 --> P["code + token ready"]
H2 --> P
P --> Q["Pre-fills signup<br/>POST /claim {device_id, token, method}"]
Q --> R["Verified → conversion recorded<br/>reward distributed"]
classDef ghost fill:transparent,stroke-dasharray: 2 3,color:#888
class X1,X2 ghost
Reached only when the deterministic path above didn't run or didn't have anything to read — a scored guess, backed by the same signed token once it succeeds. Both platforms feed the same scoring engine; this is the one piece of matching logic that isn't platform-specific at all.
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Share link tapped"] --> B["Landing page (referral-web)<br/>POST /click"]
B --> C["Backend stores click, signs token<br/>click_id.expiry.hmac"]
C --> D1[Android]
C --> D2[iOS]
subgraph android [ANDROID]
D1 --> E1["Install Referrer empty or sideload<br/><i>fallback only — see Deterministic</i>"]
end
subgraph ios [iOS]
D2 --> E2["Automatic, every launch, no gesture required<br/><i>iOS's default recovery attempt</i>"]
end
E1 --> M["<b>Match Engine</b><br/>scores fingerprint vs. recent unmatched clicks<br/>IP · device · screen · timezone · language · recency"]
E2 --> M
M --> N{"score ≥ min_confidence<br/>(default 70)?"}
N -->|yes| O1["<b>method: fingerprint</b><br/>click locked to device, atomically"]
N -.->|no| O2["No match<br/>code: null — manual entry fallback"]
O1 --> P["code + token ready<br/>(or a manual code, no token, on no match)"]
O2 -.-> P
P --> Q["Pre-fills signup<br/>POST /claim {device_id, token, method}"]
Q --> R["Verified → conversion recorded<br/>reward distributed"]
classDef nomatch stroke-dasharray: 2 3
class O2 nomatch
Reliability: Android deterministic recovery hits ~100% of real installs; iOS deterministic recovery depends entirely on whether the paste button is rendered and tapped. Probabilistic matching sees an observed ~85–90% match rate on iOS, where it's the default fallback; lower priority on Android, where it's a fallback of last resort.
Treat that iOS figure as what a single self-hosted deployment sees, and don't compare it directly against a commercial MMP's published numbers. Branch and AppsFlyer score probabilistic matches against signal pooled across their whole SDK install base — many apps, shared IP graphs — while this only ever sees your own clicks. That's a structural difference in available signal, not a tuning gap you can close by adjusting weights.
The proof that makes both deterministic paths possible: every
recovered code carries a signed proof (click_id.expiry.hmac), minted
once at /click — this is what lets both deterministic paths stay
genuinely network-free at recovery time, and what /claim verifies before
any reward is paid out. A code with no valid token behind it (including
one a user typed in by hand) can reach signup, but can never clear
/claim — see docs/decisions.md #21/#22 for the
full reasoning behind why this replaced an earlier redeem-round-trip
design.
Read the scope line first, because it's the part that decides whether this is for you: Branch and AppsFlyer are mobile measurement platforms. blynk-deferlink is the deferred-deep-linking and referral-attribution slice of what they do, self-hosted. If you need ad-network attribution, this is not a replacement.
| blynk-deferlink | Branch / AppsFlyer | |
|---|---|---|
| Hosting | your infrastructure | SaaS |
| Click and conversion data | your database | their cloud |
| Cost | your hosting bill | see below |
| Source | MIT, forkable | closed |
| Vendor lock-in | none — it's your schema | re-instrumentation to leave |
| Third-party data sharing | none | governed by their terms |
Worth being blunt about, because it's most of what the money buys:
apple-app-site-association and assetlinks.json files and handles
domain verification for you; here that's yours to configure. The landing
page does attempt to open an already-installed app via its custom URL
scheme, but that attempt is deliberately fired into a hidden iframe and
is effectively inert — no custom-scheme technique is both silent when the
app is absent and functional when it's present. Only Universal Links and
App Links solve that properly. See
docs/decisions.md #27 for the full reasoning.
Deferred deep linking — the actual point of this project — works
regardless, because it runs after install rather than at link-tap.referral_clicks table can cross-match: fingerprint matching considers
every recent click, and the five non-recency signals (IP, device model,
screen, timezone, language) sum to 85 against a default threshold of 70.
Two apps aimed at the same user base — a consumer app and a companion
app, say — clear that bar on ordinary household overlap rather than by
coincidence, and the second app's user gets attributed the first app's
referrer. Run a separate deployment and database per app until app
scoping lands; see docs/decisions.md #32 for the
analysis and the planned fix.docs/decisions.md.When to use them instead: you're running paid acquisition and need MMP attribution; you want fraud protection you don't have to build; or you'd rather buy a supported product than operate one. Those are good reasons, and this project doesn't pretend otherwise.
When to use this: referral and invite flows are the attribution you care about, you want the data in your own database, or a per-MAU bill doesn't make sense for your margins.
FDL was deprecated in May 2023 with no official replacement and shut down completely on 25 August 2025 — every link stopped resolving, deferred linking included. If that's what brought you here, this is probably a closer fit than Branch is.
FDL was never an MMP. It did link hosting, Universal/App Links handling, deferred deep linking, and basic click analytics — and nothing else. No ad-network attribution, no fraud detection, no campaign analytics. So the honest mapping is:
| FDL did | here |
|---|---|
| Deferred deep linking | ✅ the core of this project |
| Basic click tracking | ✅ every click is a row in your database |
Link hosting (page.link) | ❌ your own domain, your own routes |
| Universal Links / App Links setup | ❌ yours to configure |
In other words: this covers the deferred-attribution half of FDL, not the link-hosting half. That's the honest scope. If all you needed was the deferred part — which for referral flows is usually the case — the gap is smaller than it looks, and you get to keep the data.
Running in production at Sparkle, a Nigerian microfinance bank, on the PHP backend — real referral traffic, real conversions. A second Sparkle app is rolling out on it now. The Node backend serves this project's own live demo.
See it live in production: https://sparkle.ng/referral/L82WDL — a real Sparkle referral link, served by this SDK. Open it on a phone to watch the actual flow: the landing page registers a click, hands the code off, and the app recovers it after install. (It's the maintainer's own referral link — Sparkle only credits a referral once a referred user completes a first transaction, so opening it to watch the mechanics does nothing. It's here because a real production link demonstrates more than a staged one.)
One thing worth knowing: coverage is uneven by package. The scoring
engine, click tokens and conversion tracking are well covered on both
backends; referral-web's test suite is new and still thin. CI runs every
suite on each PR across both Node and PHP version ranges.
docs/decisions.md is the honest record of how this was built — including
the bugs found by actually running it on real devices, and the trade-offs
accepted rather than solved. It's the fastest way to judge whether the
engineering here meets your bar.
Don't want to clone anything yet? Try the recovery flow live — pick a referral code and it builds a real link to the actual production landing page (real countdown, real click registration, real clipboard handoff), then recovers it exactly how a real installed app would (Android: automatic via the Play referrer param; iOS: a real clipboard check, falling back to a real fingerprint match), with the actual request/response for every step.
Want the real mobile app, not just the web half? Download the Android demo APK — open that link on an Android device and install it; no Play Store listing and no Expo account needed. You'll have to allow installs from your browser the first time, since it's unlisted rather than Play-distributed. It's served from a GitHub release deliberately: the EAS build link this used to point at expires after 30 days, and did — a release asset doesn't. iOS has no equivalent link — internal distribution there needs a registered Apple Developer account and per-device UDID registration, so for now the iOS side is only reachable by building it yourself (see below), or by watching it run in production in See it working above.
Otherwise, the fastest way to see the whole thing work locally, before installing anything for real. Three terminals. No PHP or database required — the mock backend covers it.
# 1) backend (http://localhost:8787)
node examples/mock-backend/server.js
# 2) web landing page (http://localhost:5173/?code=1234)
npm install
npm --workspace examples/web run dev
# 3) mobile app
npm --workspace examples/mobile run start
Open the web page with ?code=1234 and watch the backend log the click. On
desktop it shows both store buttons; on a phone browser it attempts the app
handoff then redirects to the store.
The mobile app recovers automatically on launch, same as a real app — there's nothing to simulate inside it. To see it actually find something, type a code into its own "Generate a referral link" card and tap Open link — that opens the real landing page in your device/simulator's browser (real countdown, real click registration, real clipboard handoff), then switch back to the app to see the recovery.
expo run:ios /
expo run:android), not Expo Go, because react-native-device-info and
react-native-play-install-referrer include native code. (The SDK itself
persists nothing to disk — no AsyncStorage or other storage dependency at
all; see packages/referral-mobile/README.md.)localhost won't reach your machine — set API in
examples/mobile/App.tsx to your computer's LAN IP.react-native-play-install-referrer is required for Android, not
optional — Android's deterministic recovery path depends on it, and
useReferralCode() throws a clear error if it's missing rather than
silently falling back to the weaker fingerprint-matching path. (This SDK
previously referenced a package called react-native-android-install-referrer,
which turned out to not exist on npm at all — see
packages/referral-mobile/README.md.)<ReferralLanding> always
tries myapp://referral?code=... first, before falling through to the
countdown/store redirect — confirmed with xcrun simctl openurl against a
real installed build. On a real production build this routes straight
into the app, no dialog. On an Expo development build specifically
(what expo run:ios/expo run:android produce), Expo's own dev-client
intercepts every custom-scheme link first with a "which dev server should
open this?" picker (it supports pointing one binary at several Metro
instances) — pick this app's entry there and it proceeds normally.
Standard Expo tooling behavior, not something this SDK or example
controls.Once the demo makes sense, this is where to go depending on what you're actually trying to do:
Setting this up for real — pick the piece you need:
| Guide | What it covers |
|---|---|
docs/integration/referral-sdk.md | PHP backend — Laravel or standalone, zero to a verified /click → /claim round trip |
docs/integration/referral-sdk-node.md | Node backend — local run, Postgres setup, Vercel deploy |
docs/integration/referral-web.md | Landing page — provider config, rendering <ReferralLanding>, verifying a click registers |
docs/integration/referral-mobile.md | Mobile app — install, wiring useReferralCode(), on-device verification |
docs/integration/README.md | Index — what order to read the above in |
The four recovery paths (deterministic/probabilistic × Android/iOS), diagrammed, live in How it works above rather than a separate doc — it's central enough to the project to read before anything else, not something to click away for.
Understanding why it's built this way, not just how to use it:
docs/decisions.md — the engineering-decisions log.
Every non-obvious choice in this codebase (why matching uses a graduated
recency score, why /claim requires a signed token instead of trusting the
request, why the mobile SDK persists nothing to disk) is written up there
with the problem it solved, in chronological order.
API/config reference for a package you've already set up lives in that
package's own README (packages/*/README.md) — the integration guides above
are the walkthrough; the READMEs are what to come back to for a specific
config field or endpoint shape.
Fork it and run it as your own. That's the intended model, not a temporary state of affairs. Attribution data is the thing you least want sitting behind someone else's API: every click, every device fingerprint, every conversion. Self-hosting means the click table lives in your database, the matching runs on your server, and nobody can reprice, rate-limit, sunset, or read it. Firebase Dynamic Links shut down in August 2025 and took every link with it; that risk is the whole reason this exists, and shipping it as a hosted dependency would just recreate it.
So there's no registry install. Fork or clone, point the backend at your database, and change whatever doesn't fit — the code is yours at that point, and the API contract is small enough to modify without fear (two independent backends implement it, which is the proof it's not tangled).
The full reasoning, including what this costs you — slower to adopt than
npm install, and forks get no automatic security updates — is
decision #31.
This is how it runs in production today. Sparkle, a
Nigerian microfinance bank, runs its referral programme on it: the PHP
backend SDK deployed on their own infrastructure, against their own
database and their own reward logic. Nothing in this repo is a staging
copy of that — it's the same code path, which is why the bugs in
docs/decisions.md were found on real devices rather
than in tests.
The packages are consumed from source. Three ways, depending on how your project is laid out:
1. Workspaces (what this repo uses). From the repo root:
npm install # links packages/* and installs example deps
The examples already declare the SDKs as file: dependencies, so they resolve
to the local source automatically.
2. A file: dependency in your own project.
// package.json
"dependencies": {
"@blynk-deferlink/referral-mobile": "file:/path/to/packages/referral-mobile"
}
React Native's Metro bundles the SDK straight from its TypeScript source (the
package's react-native field points at src/), so no build step is needed for
the mobile SDK. The web SDK is consumed from source here via a Vite alias; for a
non-source consumer, build it first (below).
3. A tarball. Build, pack, then install the .tgz anywhere:
npm --workspace @blynk-deferlink/referral-web run build
npm --workspace @blynk-deferlink/referral-web pack # → blynk-deferlink-referral-web-1.0.0.tgz
npm install ./blynk-deferlink-referral-web-1.0.0.tgz
Publishing your fork privately. If your team would rather consume these
as ordinary dependencies than as source, publish them to your own registry
under your own scope: npm run build:sdks, then npm publish in each
package, and composer / Packagist for the PHP one. Renaming the scope in
each package.json is the only change required.
packages/referral-sdk (PHP)
or packages/referral-sdk-node (Node/Express + Postgres, deployable to
Vercel or any Node host). Same API contract; the web/mobile SDKs don't care
which one is behind apiEndpoint. See the Documentation
section above for step-by-step setup guides for all four packages.code_validator) and reward distribution
(on_claim_callback) in whichever backend config you use.buildReferralMeta.Contributions are genuinely welcome, and the project is set up so that a first one is easy to land.
CONTRIBUTING.md covers setup, how to run each
package's tests, and what to verify before opening a PR. CI runs every
suite on each PR across both Node and PHP version ranges, so you get
automated feedback without waiting on a review.
Good first issues are labelled
good first issue
— currently small, self-contained test-coverage tasks in the web SDK, each
one a single file you can read in a couple of minutes.
Two things worth knowing before you start:
docs/decisions.md is the map. Every non-obvious
choice in this codebase has a numbered entry explaining the problem, the
decision, and what was actually built. If something looks wrong, check
there first — it's often deliberate, and the entry will say why.135 commits
1 commits
TypeScript
58.3%
PHP
38.7%
Swift
2.1%