Documentation
Capacitor, in depth
The Capacitor path in full, once the quickstart has you running. The plugin it reads, what happens on a cold start, and what to check first.
You need a Mac, an Apple Developer account, and your app’s bundle identifier. With those in hand the code below is the whole integration. Expect more than one build if the Associated Domains entitlement is not already on your app, and note that Apple caches your association file for up to 24 hours — that delay is the commonest reason a correct setup looks broken.
One package of ours, plus one Capacitor plugin you may already have. @quberoute/sdk is plain TypeScript with no dependencies of its own — no native module, no pod install for it, nothing to rebuild natively when you upgrade it.
It reads incoming links through @capacitor/app, which is Capacitor’s own plugin for launch URLs and which most Ionic apps already depend on. If yours does not, install it with the line below. The SDK will tell you if it is missing — through onError, naming the fix — rather than opening your app and quietly never firing onLink.
1. Create an app, and get your key
Sign in, create an app, and open its SDK setup page. That page shows the four values in this guide — your link host, your publishable key, your bundle identifier and your Team ID — already filled in, so you can copy rather than adapt.
Use a publishable key, which begins qr_live_ or qr_test_. It is compiled into your app and shipped to the public, so it is designed to be public: it can report installs, resolve your own links and record events, and it can do nothing else. Never ship a secret key (qr_sk_…) inside an app — the SDK will refuse it and tell you why.
Start in test. Test links, keys and installs are completely separate from live and are never billed.
2. Install the package
npm install @quberoute/sdk @capacitor/app
npx cap sync3. Add the associated domain
This is the part iOS requires and it is the part that most often goes wrong. In Xcode, select your target, then Signing & Capabilities, then + Capability → Associated Domains, and add one entry using your own link host from the SDK setup page:
applinks:YOUR-KEY.qbrt.app
applinks:YOUR-KEY-test.qbrt.appThere are two hosts, not one. Live links are on YOUR-KEY.qbrt.app and test links are on YOUR-KEY-test.qbrt.app. Declare both now: a host you have not declared cannot open your app at all, and adding the second one later means another provisioning profile and another wait for Apple’s cache.
The key you initialise with must match the environment of the link you are opening. A live link with a test key is refused, and so is the reverse. This is the single commonest integration failure, it is invisible from the outside, and the two hosts differing by one suffix is what makes it easy to do. Copy the host and the key together from the same SDK setup page and they cannot disagree.
Your Apple Developer account must have Associated Domains enabled for this App ID, and the provisioning profile must be regenerated afterwards. Xcode usually does this for you when automatic signing is on. If it does not, that is the first thing to check when nothing works.
While you are testing, add ?mode=developer to the associated domain entry — see the Apple caching delay, which is the single commonest reason a correct setup appears broken.
4. Five lines
In whichever file runs once when your app starts — app.component.ts in an Ionic Angular app, App.tsx in Ionic React:
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { QubeRoute } from '@quberoute/sdk'
@Component({ selector: 'app-root', templateUrl: 'app.component.html' })
export class AppComponent implements OnInit {
constructor(private router: Router) {}
async ngOnInit() {
const qr = await QubeRoute.init({
// The real key from your SDK setup page, matching the environment of
// the links you are opening.
key: 'qr_live_...',
// Keep this while you integrate. If the key is wrong, the environment
// does not match, or @capacitor/app is missing, this is where it says so.
onError: e => console.log('QubeRoute:', e),
})
qr.onLink(link => {
// link.data is whatever you put in the link when you created it.
this.router.navigateByUrl(link.data.path as string)
})
}
}That is the integration. init never throws and never waits for the network — it resolves as soon as it has read local storage, and the link arrives at onLink a moment later.
Register onLink straight after init. On a cold start the link is often resolved before your handler exists; the SDK holds it and delivers it the instant you register, so the order above is safe. If you register it three screens later, it still arrives — but your user has already seen the wrong screen.
5. Track what matters
await qr.track('purchase', { value: 4.99, currency: 'GBP' })Write the value as a person would — 4.99, not 499. The currency is required whenever there is a value. track never throws — but it does await the send, up to the three-second timeout, so do not put it in front of something a person is waiting for. If the device is offline the event is queued and goes out with the next one, up to a hundred events.
6. Test it
Create a link in the dashboard, in test, with some deep link data. Then:
- Put the link in a note or a message on the device — not in Safari’s address bar. Typing a universal link into the address bar does not open the app; that is iOS behaviour and it catches everybody.
- Tap it. Your app should open and
onLinkshould fire. - If Safari opens instead, go to when it does not work. Do not guess — the list there is ordered by how often each cause is the real one.
The click count will not move, and that is correct
A link that opens your app never reaches our servers, so no click is recorded. That is how universal links work: iOS matches the address against your Associated Domains and hands it straight to your app, without making the request. Seeing your app open is the success signal.
A click is counted when somebody without your app taps the link and is sent to the App Store — which is the case that matters for attribution, because it is the one that produces an install to match. If you want to see a click while testing, open the link on a device that does not have the app.
Two addresses that will not do what you expect
Scan or tap the short link — YOUR-KEY.qbrt.app/your-alias — and nothing else.
- A custom scheme such as
yourapp://…will open your app, because iOS registers it, but it is not a QubeRoute link. The SDK will not recognise it,onLinkwill not fire, and nothing will reach us. - The path form,
qbrt.app/l/YOUR-KEY/your-aliasresolves and records a click, but it cannot open your app — that path is excluded from the association file deliberately, so the redirect service can answer it. It is for checking a link in a browser.
What happens when somebody does not have the app
They go to the App Store, install, and open it. iOS tells your freshly installed app nothing about the link that sent them there — so we match the install against recent clicks instead, and onLink fires with source: 'deferred' and a confidence below 1.
This is genuinely imperfect and we would rather you knew the numbers before you build anything on them: deferred matching, and how well it works.
Your own fields on a link
Anything you put on the end of a link arrives with it. Print a QR code pointing at https://your-key.qbrt.app/spring?visitor_id=9f3a&affiliate_id=ACME and both fields reach your app at first launch.
qr.onLink(link => {
const affiliate = link.params.affiliate_id // 'ACME'
const visitor = link.params.visitor_id // '9f3a'
// The raw query string, exactly as it arrived, when you need to be certain.
console.log(link.query) // 'visitor_id=9f3a&affiliate_id=ACME'
})They arrive on all four routes into your app — an installed app opening a universal link, the “open in the app” offer, a Cordova cold start, and a deferred install where somebody went to the App Store first. The last one is the one that matters for affiliate payments, and it works because the fields are written down at the moment of the click rather than read off a URL that never reaches the device.
params is kept separate from data deliberately. data is what you configured when you made the link; params is what was on the URL this time. Merging them would let a query parameter quietly overwrite a field your app routes on.
Four things worth knowing, because none of them is guessable:
- A repeated key keeps every value.
?tag=a&tag=bgives you['a', 'b']. A key that appears once is a plain string. - A
+means a space.affiliate_id=ACME+2026arrives asACME 2026. That is standard form-encoding and we will not override it. If your identifiers can contain a literal plus, encode it as%2B. - Values are decoded once. A double-encoded value loses exactly one layer, which is the only predictable answer.
- Over 2000 characters we keep nothing rather than keeping part. Half an affiliate id is worse than none, because it looks like data.
Before you go live
- Swap the test key for the live one and add the live associated domain. They are different hosts:
YOUR-KEYandYOUR-KEY-test. - Remove
?mode=developerfrom the associated domain. - Fill in your privacy manifest — what to declare. A pure JavaScript package ships no manifest of its own, so this is yours to complete.
Ask the documentation
It answers from these pages only, and links what it used. If the answer is not here it says so rather than guessing — then email [email protected].