@straitlink/mcp 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "source": "https://straitlink.in",
3
3
  "builtFrom": "site/content/docs",
4
+ "siteCommit": "23f8d53",
4
5
  "sections": [
5
6
  {
6
7
  "page": "What is Strait?",
@@ -14,84 +15,84 @@
14
15
  "group": "Get started",
15
16
  "section": "How it fits together",
16
17
  "url": "https://straitlink.in/docs/#how-it-fits-together",
17
- "markdown": "1. Your workspace gets a link domain, `<your-handle>.strait.link`, and two keys: a publishable key for your app and a secret key for your server ([Keys](https://straitlink.in/docs/keys/)).\n2. You create links in the dashboard or with the [REST API](https://straitlink.in/docs/api/).\n3. The Strait SDK in your app gets one `onLink` callback for every way a link opens it, and reports each open for Analytics.\n\nAndroid and web are live. iPhone is in beta. Live service health is on the [status page](https://status.strait.link)."
18
+ "markdown": "1. Your workspace gets a link domain, `<your-handle>.strait.link`, and two keys: a publishable key for your app and a secret key for your server ([Keys](https://straitlink.in/docs/keys/)).\n2. You create links in the dashboard or with the [REST API](https://straitlink.in/docs/api/).\n3. The Strait SDK in your app gets one `onLink` callback for every way a link opens it, and reports each open for Analytics.\n\nAndroid, iPhone and web links are live. iPhone install matching is opt-in. Live service health is on the [status page](https://status.strait.link). Free up to 10,000 taps a month, no card; app opens are never billed ([pricing](https://straitlink.in/pricing/))."
18
19
  },
19
20
  {
20
21
  "page": "What is Strait?",
21
22
  "group": "Get started",
22
23
  "section": "Start here",
23
24
  "url": "https://straitlink.in/docs/#start-here",
24
- "markdown": "- **[10-minute quickstart](https://straitlink.in/docs/quick-start/)**: see a link open your app on an Android phone.\n- **[Pick your platform](https://straitlink.in/docs/sdks/)**: React Native, Expo, Flutter, Android, iOS, Unity, web.\n- **Leaving Firebase Dynamic Links?** [Migrate step by step](https://straitlink.in/docs/migrate-from-firebase/).\n- **Using an AI coding tool?** [Set up with Claude, Cursor or ChatGPT](https://straitlink.in/ai/)."
25
+ "markdown": "- **[10-minute quickstart](https://straitlink.in/docs/quick-start/)**: see a link open your app on an Android phone.\n- **[Pick your platform](https://straitlink.in/docs/sdks/)**: React Native, Expo, Flutter, Android, iOS, Unity, web.\n- **Leaving Firebase Dynamic Links or Branch?** [Firebase, step by step](https://straitlink.in/docs/migrate-from-firebase/) · [Branch, step by step](https://straitlink.in/docs/migrate-from-branch/).\n- **Using an AI coding tool?** [Set up with Claude, Cursor or ChatGPT](https://straitlink.in/ai/)."
25
26
  },
26
27
  {
27
28
  "page": "Quickstart: open your app from a link (Android)",
28
29
  "group": "Get started",
29
30
  "section": null,
30
31
  "url": "https://straitlink.in/docs/quick-start/",
31
- "markdown": "This page uses the shortest path: your app's custom scheme. Verified App Links (no browser in between), deferred links and iPhone come after, in the [go-live checklist](https://straitlink.in/docs/production-checklist/). On Expo? Steps 1 to 3 are the same; for steps 4 and 5 follow [Expo](https://straitlink.in/docs/sdks/expo/)."
32
+ "markdown": "Six steps. Do them in order. Each step says what to do, what to copy, and what you should see when it worked. If you don't see it, stop and check [Common errors](#common-errors) before the next step.\n\nTwo words you'll meet:\n\n- **Deep link**: a link that opens your app on a particular screen, not just its home screen.\n- **SDK**: the small library you add to your app. It tells your app which screen a link wants.\n\nOn Expo? Steps 1 to 3 are the same; for steps 4 and 5 follow [Expo](https://straitlink.in/docs/sdks/expo/). Flutter: [Flutter](https://straitlink.in/docs/sdks/flutter/). Kotlin: [Android](https://straitlink.in/docs/sdks/android/)."
32
33
  },
33
34
  {
34
35
  "page": "Quickstart: open your app from a link (Android)",
35
36
  "group": "Get started",
36
37
  "section": "1. Create a workspace",
37
38
  "url": "https://straitlink.in/docs/quick-start/#1-create-a-workspace",
38
- "markdown": "[Sign up](https://app.straitlink.in/?signup=1) and name your workspace. The name becomes your link domain, `https://<your-handle>.strait.link`. These docs use `hilltop.strait.link`.\n\n:::warn Pick your handle before you create a link\nTo change it, go to **Dashboard → Get started** before your first link. After that the handle is fixed, because shared links depend on it.\n:::\n\n**You should see:** your link domain and your publishable key (`st_pub_live_…`) in **Dashboard → Get started**."
39
+ "markdown": "**Do this:** [sign up](https://app.straitlink.in/?signup=1) and type a name for your workspace. That name becomes your link address, `https://<your-handle>.strait.link`. These docs write it as `hilltop.strait.link`.\n\n:::warn Pick your handle before you create a link\nTo change it, go to **Dashboard → Get started** before your first link. After that the handle is fixed, because shared links use it.\n:::\n\n**You should see:** your link address and your **publishable key** (starts with `st_pub_live_…`) in **Dashboard → Get started**. The publishable key is safe to put in your app."
39
40
  },
40
41
  {
41
42
  "page": "Quickstart: open your app from a link (Android)",
42
43
  "group": "Get started",
43
- "section": "2. Add your Android app",
44
- "url": "https://straitlink.in/docs/quick-start/#2-add-your-android-app",
45
- "markdown": "In **Dashboard → Apps & stores**, under **Android**, fill in two fields:\n\n| Field | Example | Where to find it |\n|---|---|---|\n| Package name | `com.yourcompany.yourapp` | `applicationId` in `android/app/build.gradle` |\n| Custom scheme | `yourapp` | Any short lowercase word your app will answer to |\n\nPress **Save app details**.\n\n**You should see:** **Saved ✓** next to the button. Leave everything else for now."
44
+ "section": "2. Tell us about your Android app",
45
+ "url": "https://straitlink.in/docs/quick-start/#2-tell-us-about-your-android-app",
46
+ "markdown": "**Do this:** open **Dashboard → Apps & stores**. Under **Android**, fill in two boxes, then press **Save app details**.\n\n| Box | Example | Where to find it |\n|---|---|---|\n| Package name | `com.yourcompany.yourapp` | `applicationId` in `android/app/build.gradle` |\n| Custom scheme | `yourapp` | Make one up: a short lowercase word, like your app's name. It's the private address your app answers to (`yourapp://`) |\n\n**You should see:** **Saved ✓** next to the button. Leave every other box empty for now."
46
47
  },
47
48
  {
48
49
  "page": "Quickstart: open your app from a link (Android)",
49
50
  "group": "Get started",
50
- "section": "3. Create a link",
51
- "url": "https://straitlink.in/docs/quick-start/#3-create-a-link",
52
- "markdown": "In **Dashboard → Links**, fill in the **New link** form at the top: **Short path** `summer`, **Destination** `https://shop.example.com/product/42`, then **Create**. Or from a terminal:\n\n```bash\nexport SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"slug\": \"summer\", \"longUrl\": \"https://shop.example.com/product/42\" }'\n```\n\nThe destination doesn't need to exist yet. Your app only receives its path and query.\n\n**You should see:** `https://hilltop.strait.link/summer` in your link list (the API answers `201` with that `shortUrl`)."
51
+ "section": "3. Make a link",
52
+ "url": "https://straitlink.in/docs/quick-start/#3-make-a-link",
53
+ "markdown": "**Do this:** open **Dashboard → Links**. In the **New link** form at the top, type **Short path** `summer` and **Destination** `https://shop.example.com/product/42`. Press **Create**.\n\nPrefer a terminal? Paste this instead. The **secret key** is in **Dashboard → Settings → Secret keys**. Keep it on your computer or server, never in an app.\n\n```bash\nexport SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"slug\": \"summer\", \"longUrl\": \"https://shop.example.com/product/42\" }'\n```\n\nThe destination page doesn't need to exist. Your app only gets its path, `/product/42`.\n\n**You should see:** `https://hilltop.strait.link/summer` in your link list. (The terminal answer is `201` with that `shortUrl`.)"
53
54
  },
54
55
  {
55
56
  "page": "Quickstart: open your app from a link (Android)",
56
57
  "group": "Get started",
57
- "section": "Shortcut: let `strait init` write steps 4 and 5",
58
- "url": "https://straitlink.in/docs/quick-start/#shortcut-let-strait-init-write-steps-4-and-5",
59
- "markdown": "From your app's folder, run:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt recognises a React Native, Expo, Flutter or Android app from its files, writes the `links` file from step 5 with your key and host, and prints the step 4 intent filter and the install command filled in with your scheme. It never changes a file you already have; add `--dry-run` to see everything first."
58
+ "section": "Shortcut: let `strait init` do steps 4 and 5",
59
+ "url": "https://straitlink.in/docs/quick-start/#shortcut-let-strait-init-do-steps-4-and-5",
60
+ "markdown": "In your app's folder, paste:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt spots a React Native, Expo, Flutter or Android app, writes the `links` file from step 5 with your key and address, and prints the step 4 lines and the install command with your scheme filled in. It never changes a file you already have. Add `--dry-run` to see everything first."
60
61
  },
61
62
  {
62
63
  "page": "Quickstart: open your app from a link (Android)",
63
64
  "group": "Get started",
64
65
  "section": "4. Let your app answer to its scheme",
65
66
  "url": "https://straitlink.in/docs/quick-start/#4-let-your-app-answer-to-its-scheme",
66
- "markdown": "Add this intent filter to your main activity, using the scheme from step 2:\n\n```xml title=\"android/app/src/main/AndroidManifest.xml\"\n<activity android:name=\".MainActivity\" android:launchMode=\"singleTask\" android:exported=\"true\">\n \n <intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"yourapp\" />\n </intent-filter>\n</activity>\n```\n\n**You should see:** the app builds and runs as before (`npx react-native run-android`)."
67
+ "markdown": "**Do this:** open `android/app/src/main/AndroidManifest.xml`. Inside your main `<activity>`, add the `<intent-filter>` below. Put your own scheme from step 2 where it says `yourapp`. Keep the filters that are already there.\n\n```xml title=\"android/app/src/main/AndroidManifest.xml\"\n<activity android:name=\".MainActivity\" android:launchMode=\"singleTask\" android:exported=\"true\">\n \n <intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"yourapp\" />\n </intent-filter>\n</activity>\n```\n\n**You should see:** the app builds and runs as before when you run `npx react-native run-android`."
67
68
  },
68
69
  {
69
70
  "page": "Quickstart: open your app from a link (Android)",
70
71
  "group": "Get started",
71
- "section": "5. Install the SDK and log each link",
72
- "url": "https://straitlink.in/docs/quick-start/#5-install-the-sdk-and-log-each-link",
73
- "markdown": "Install it directly from GitHub; no sign-up or access request:\n\n```bash\nnpm install @straitlink/react-native@0.9.1 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\nCreate the client once, in a file of its own:\n\n```ts title=\"links.ts (@straitlink/react-native 0.9.1)\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started (safe in apps; never the secret key)\n endpoint: 'https://hilltop.strait.link', // your Strait link host\n storage: AsyncStorage, // required for reliable analytics\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.onLink((e) => console.log('Strait link:', e.matched, e.path, e.params));\nlinks.start();\n```\n\nImport `./links` once from your app's entry file (`index.js` or `App.tsx`), then rebuild, because the SDK adds a native module.\n\n**You should see:** the app starts normally. On this first start it logs one line, `Strait link: false undefined undefined`: the SDK checked once for a deferred link and there was none."
72
+ "section": "5. Add the SDK",
73
+ "url": "https://straitlink.in/docs/quick-start/#5-add-the-sdk",
74
+ "markdown": "**Do this:** in your app's folder, paste this install command. Install it from npm; no sign-up or access request.\n\n```bash\nnpm install @straitlink/react-native@0.9.2 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\nThen make a new file, `links.ts`, and paste this. Put your publishable key and link address in the two marked lines.\n\n```ts title=\"links.ts (@straitlink/react-native 0.9.2)\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started (safe in apps; never the secret key)\n endpoint: 'https://hilltop.strait.link', // your Strait link host\n storage: AsyncStorage, // required for reliable analytics\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.onLink((e) => console.log('Strait link:', e.matched, e.path, e.params));\nlinks.start();\n```\n\nLast, add `import './links';` near the top of your entry file (`index.js` or `App.tsx`). Rebuild the app, because the SDK adds native code.\n\n**You should see:** the app starts normally and prints one line: `Strait link: false undefined undefined`. That's right: on the first start the SDK checks once for a link and finds none."
74
75
  },
75
76
  {
76
77
  "page": "Quickstart: open your app from a link (Android)",
77
78
  "group": "Get started",
78
79
  "section": "6. Tap the link",
79
80
  "url": "https://straitlink.in/docs/quick-start/#6-tap-the-link",
80
- "markdown": "Send `https://hilltop.strait.link/summer` to yourself in Google Messages or Gmail, and tap it on the phone. Don't paste it into the browser's address bar: phones only open apps from links that are tapped.\n\nOn an emulator, or a phone plugged in over USB, this counts as a tap too:\n\n```bash\nadb shell am start -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"\n```\n\n**You should see:** the browser flashes for a moment, your app opens, and it logs `Strait link: true /product/42 {}`. Since React Native 0.77, app logs show in React Native DevTools (press **j** in the Metro terminal), not in the Metro terminal itself; `adb logcat -s ReactNativeJS` shows them too. In **Dashboard → Analytics**, the tap and the app open appear within a minute.\n\nTapping in WhatsApp or Instagram can show an **Open in app** page first. That's expected: in-app browsers often don't open apps directly."
81
+ "markdown": "**Do this:** send `https://hilltop.strait.link/summer` to yourself in Google Messages or Gmail. Tap it on the phone. Don't paste it into the browser's address bar: phones only open apps from links you tap.\n\nOn an emulator, or a phone plugged in by USB, this command counts as a tap:\n\n```bash\nadb shell am start -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"\n```\n\n**You should see:** the browser flashes for a moment, then your app opens and prints `Strait link: true /product/42 {}`. Logs are in React Native DevTools (press **j** in the Metro terminal) or `adb logcat -s ReactNativeJS`. Within a minute, the tap and the app open show in **Dashboard → Analytics**.\n\nWhatsApp or Instagram may show an **Open in app** page first. That's normal: apps' built-in browsers often won't open other apps by themselves."
81
82
  },
82
83
  {
83
84
  "page": "Quickstart: open your app from a link (Android)",
84
85
  "group": "Get started",
85
86
  "section": "Common errors",
86
87
  "url": "https://straitlink.in/docs/quick-start/#common-errors",
87
- "markdown": "- **Chrome opens the Play Store or the destination instead of the app.** The scheme in **Apps & stores** and in the manifest differ, or the app on the phone is an old build without the intent filter.\n- **The app opens but nothing is logged.** `links.ts` isn't imported at startup, or the app wasn't rebuilt after installing the SDK.\n- **`matched` is `false` with `reason: \"not_found\"`.** The publishable key is from another workspace. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
88
+ "markdown": "- **Chrome opens the Play Store or the website, not the app.** The scheme in **Apps & stores** and the one in the manifest are different, or the phone has an old build without step 4. Fix the scheme, rebuild, try again.\n- **The app opens but prints nothing.** `links.ts` isn't imported when the app starts, or you didn't rebuild after step 5.\n- **It prints `false` with `reason: \"not_found\"`.** The publishable key is from another workspace. Copy it again from **Get started**. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
88
89
  },
89
90
  {
90
91
  "page": "Quickstart: open your app from a link (Android)",
91
92
  "group": "Get started",
92
93
  "section": "Next steps",
93
94
  "url": "https://straitlink.in/docs/quick-start/#next-steps",
94
- "markdown": ":::tip Next: deferred deep links\nThis page covers a link opening an app that's already installed. For a new user who installs first, you add **no extra code**: the same `onLink` callback fires with `kind: 'deferred'`. You need a build installed from Google Play and, while the app isn't public, **Always Google Play** in **Apps & stores**. [Set up deferred links →](https://straitlink.in/docs/deferred-deep-linking/#what-your-app-sees)\n:::\n\n- [Open the right screen](https://straitlink.in/docs/sdks/react-native/receive-links/): route `e.path` with React Navigation, or see [Expo Router](https://straitlink.in/docs/sdks/expo/). Not on React Native? [Flutter](https://straitlink.in/docs/sdks/flutter/) · [Android](https://straitlink.in/docs/sdks/android/).\n- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified App Links, deferred links after install, and iPhone."
95
+ "markdown": ":::tip You're done. What it costs\nFree up to 10,000 taps a month, no card. App opens are never billed. New users who install first? The same `onLink` fires with `kind: 'deferred'`, no extra code: [set up deferred links](https://straitlink.in/docs/deferred-deep-linking/#what-your-app-sees).\n:::\n\n- [Open the right screen](https://straitlink.in/docs/sdks/react-native/receive-links/): send `e.path` to a screen with React Navigation, or see [Expo Router](https://straitlink.in/docs/sdks/expo/).\n- [Go-live checklist](https://straitlink.in/docs/production-checklist/): links that skip the browser, links after an install, and iPhone."
95
96
  },
96
97
  {
97
98
  "page": "Go-live checklist",
@@ -117,9 +118,9 @@
117
118
  {
118
119
  "page": "Go-live checklist",
119
120
  "group": "Get started",
120
- "section": "4. Set up iPhone (beta)",
121
- "url": "https://straitlink.in/docs/production-checklist/#4-set-up-iphone-beta",
122
- "markdown": "1. In **Dashboard → Apps & stores**, add your Apple Team ID, bundle ID and App Store ID.\n2. In Xcode, add the **Associated Domains** capability with `applinks:hilltop.strait.link`, and your custom scheme under URL Types.\n3. Read [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/) before you fill in the App Store privacy label.\n\n```bash\ncurl https://hilltop.strait.link/.well-known/apple-app-site-association\n```\n\n**You should see:** `<Team ID>.<bundle ID>` in that file. iOS fetches it through Apple's CDN, which can lag behind changes. iPhone support hasn't been proven on a real iPhone yet."
121
+ "section": "4. Set up iPhone",
122
+ "url": "https://straitlink.in/docs/production-checklist/#4-set-up-iphone",
123
+ "markdown": "1. In **Dashboard → Apps & stores**, add your Apple Team ID, bundle ID and App Store ID.\n2. In Xcode, add the **Associated Domains** capability with `applinks:hilltop.strait.link`, and your custom scheme under URL Types.\n3. Read [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/) before you fill in the App Store privacy label.\n\n```bash\ncurl https://hilltop.strait.link/.well-known/apple-app-site-association\n```\n\n**You should see:** `<Team ID>.<bundle ID>` in that file. iOS fetches it through Apple's CDN, which can lag behind changes."
123
124
  },
124
125
  {
125
126
  "page": "Go-live checklist",
@@ -147,77 +148,84 @@
147
148
  "group": "Get started",
148
149
  "section": null,
149
150
  "url": "https://straitlink.in/docs/migrate-from-firebase/",
150
- "markdown": "Firebase Dynamic Links has been shut down, and Google no longer opens your app from `page.link` links. Some return 404; others still do a plain redirect to their old web or store fallback, with no app opening, no deferred deep link, and no promise they keep working. This guide moves the three things that depended on it: **link creation** on your backend, **link handling** in your app, and **the links themselves**.\n\nThe guide has three parts. This page plans the move and maps every Dynamic Links concept to its Strait equivalent. [Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/) changes your app. [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/) recreates your links, switches your backend and checks everything on a real phone."
151
+ "markdown": "Google shut Firebase Dynamic Links down on 25 August 2025. Google no longer opens your app from `page.link` links, and no longer carries a link through an install. Some old links still redirect to their old web page; others return an error. Either can stop at any time.\n\nLeaving Branch instead? Use [Migrate from Branch](https://straitlink.in/docs/migrate-from-branch/). The Firebase move has four parts. Each has its own page:\n\n| Part | What you do | Time |\n|---|---|---|\n| **Plan** (this page) | Find what used Dynamic Links and decide the order | 30 minutes |\n| **[Move your links](https://straitlink.in/docs/migrate-from-firebase/one-step/)** | Type your old domain once; we rebuild your links | 5 minutes, then it runs alone |\n| **[Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/)** | Change a few lines in your app | About 30 minutes per app |\n| **[Cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/)** | Point your server at Strait, test on a phone, replace old links | About 1 hour |"
151
152
  },
152
153
  {
153
154
  "page": "Migrate from Firebase Dynamic Links",
154
155
  "group": "Get started",
155
- "section": "1. List what used Dynamic Links",
156
- "url": "https://straitlink.in/docs/migrate-from-firebase/#1-list-what-used-dynamic-links",
157
- "markdown": "Search your codebases for the places Dynamic Links touched. Most apps have three or four:\n\n```bash\n# Backend: link creation through the REST API\ngrep -rn \"firebasedynamiclinks.googleapis.com\" .\n\n# Android and Flutter apps\ngrep -rn \"dynamiclinks\\|firebase_dynamic_links\\|getDynamicLink\" --include=*.kt --include=*.java --include=*.dart --include=*.gradle* .\n\n# React Native, iOS and Unity apps\ngrep -rn \"dynamic-links\\|DynamicLinks\\|handleUniversalLink\" --include=*.ts --include=*.tsx --include=*.js --include=*.swift --include=*.cs .\n```\n\nAlso list where people still see old links: email templates, push campaigns, your website, QR codes, and SMS templates registered under DLT.\n\n**You should see:** a short list of files and places. Each one maps to a step in this guide."
156
+ "section": "1. Find everything that used Dynamic Links",
157
+ "url": "https://straitlink.in/docs/migrate-from-firebase/#1-find-everything-that-used-dynamic-links",
158
+ "markdown": "**Do this:** open a terminal in each code folder (app and backend) and paste these three searches.\n\n```bash\n# Backend: link creation through the REST API\ngrep -rn \"firebasedynamiclinks.googleapis.com\" .\n\n# Android and Flutter apps\ngrep -rn \"dynamiclinks\\|firebase_dynamic_links\\|getDynamicLink\" --include=*.kt --include=*.java --include=*.dart --include=*.gradle* .\n\n# React Native, iOS and Unity apps\ngrep -rn \"dynamic-links\\|DynamicLinks\\|handleUniversalLink\" --include=*.ts --include=*.tsx --include=*.js --include=*.swift --include=*.cs .\n```\n\nThen list the places people still see old links: emails, push messages, your website, QR codes, and SMS templates (in India, the ones registered under DLT).\n\n**You should see:** a short list of files and places, usually three or four. Each one is fixed by a step in this guide."
158
159
  },
159
160
  {
160
161
  "page": "Migrate from Firebase Dynamic Links",
161
162
  "group": "Get started",
162
- "section": "2. Map link creation",
163
- "url": "https://straitlink.in/docs/migrate-from-firebase/#2-map-link-creation",
164
- "markdown": "A Dynamic Link was created by `POST https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=…` with a `dynamicLinkInfo` object. A Strait link is created by `POST /v1/links` with your secret key, and most fields move across directly:\n\n| Dynamic Links field | Strait API field | Notes |\n|---|---|---|\n| `dynamicLinkInfo.link` (the deep link) | `longUrl` | Your app receives its path and query |\n| `suffix.option` (`SHORT`, `UNGUESSABLE`) | `slug` | You choose the slug; unique per workspace |\n| `androidInfo.androidFallbackLink` (`afl`) | `androidFallbackUrl` | Where Android goes when the app can't open |\n| `iosInfo.iosFallbackLink` (`ifl`) | `iosFallbackUrl` | Where iPhone goes when the app can't open |\n| `desktopInfo.desktopFallbackLink` (`ofl`) | `desktopUrl` | Where computers go |\n| `socialMetaTagInfo.socialTitle` (`st`) | `title` | Preview in WhatsApp and other chat apps |\n| `socialMetaTagInfo.socialDescription` (`sd`) | `description` | Preview text |\n| `socialMetaTagInfo.socialImageLink` (`si`) | `imageUrl` | Preview image (https) |\n| `analyticsInfo.googlePlayAnalytics.utmCampaign` | `campaign` | Or keep `utm_*` in `longUrl`; the campaign is read from it |\n| Response `shortLink` | Response `shortUrl` | Always share `shortUrl` |\n\nSettings that belonged to the app rather than each link (`androidPackageName`, `iosBundleId`, `iosAppStoreId`) move to **Dashboard → Apps & stores**, once for the workspace.\n\n**You should see:** every field your backend sends has a row above, or appears in the table below."
163
+ "section": "2. Match each link field to its new name",
164
+ "url": "https://straitlink.in/docs/migrate-from-firebase/#2-match-each-link-field-to-its-new-name",
165
+ "markdown": "**Do this:** for each field your backend sends to Firebase (left), note its Strait name (middle). A Strait link is made with `POST /v1/links` and your **secret key** (the server-only key from **Dashboard → Settings**).\n\n| Dynamic Links field | Strait API field | What it means |\n|---|---|---|\n| `dynamicLinkInfo.link` (the deep link) | `longUrl` | Your app gets its path and query |\n| `suffix.option` (`SHORT`, `UNGUESSABLE`) | `slug` | You choose the short path; unique per workspace |\n| `androidInfo.androidFallbackLink` (`afl`) | `androidFallbackUrl` | Where Android goes when the app can't open |\n| `iosInfo.iosFallbackLink` (`ifl`) | `iosFallbackUrl` | Where iPhone goes when the app can't open |\n| `desktopInfo.desktopFallbackLink` (`ofl`) | `desktopUrl` | Where computers go |\n| `socialMetaTagInfo.socialTitle` (`st`) | `title` | Preview title in WhatsApp and other chats |\n| `socialMetaTagInfo.socialDescription` (`sd`) | `description` | Preview text |\n| `socialMetaTagInfo.socialImageLink` (`si`) | `imageUrl` | Preview image (https) |\n| `analyticsInfo.googlePlayAnalytics.utmCampaign` | `campaign` | Or keep `utm_*` in `longUrl`; we read it from there |\n| Response `shortLink` | Response `shortUrl` | Always share `shortUrl` |\n\nApp settings (`androidPackageName`, `iosBundleId`, `iosAppStoreId`) are not sent per link any more. You type them once in **Dashboard → Apps & stores**.\n\n**You should see:** every field your backend sends has a row above, or a row in step 3."
165
166
  },
166
167
  {
167
168
  "page": "Migrate from Firebase Dynamic Links",
168
169
  "group": "Get started",
169
- "section": "3. Note what works differently",
170
- "url": "https://straitlink.in/docs/migrate-from-firebase/#3-note-what-works-differently",
171
- "markdown": "| In Dynamic Links | In Strait |\n|---|---|\n| Links on `yourapp.page.link`, owned by Google | Links on your own subdomain, `<handle>.strait.link`. Custom domains are coming soon |\n| Apps could create links with the Firebase SDK and an API key | Links are created with a **secret key**, on your server. The app asks your server ([why](https://straitlink.in/docs/keys/)) |\n| `androidMinPackageVersionCode`, `iosMinimumVersion` (`amv`, `imv`) | No equivalent. Handle old app versions in your routing code |\n| `navigationInfo.enableForcedRedirect` (`efr`) and the preview page | Not needed. We pick the page per device (iPhone \"Get the app\", in-app browser \"Open in app\") |\n| `iosIpadBundleId`, `iosIpadFallbackLink` | iPads use the iPhone settings |\n| Link stats through the Firebase Dynamic Links Analytics API (`linkStats`) | **Dashboard → Analytics**, and every tap as CSV from [`GET /v1/export`](https://straitlink.in/docs/api/export/) |\n| Firebase Analytics `dynamic_link_first_open` | Opens and installs are reported by the SDK automatically; add purchases with `trackEvent` |\n\n**You should see:** nothing in this table blocks your move. If something does, email [support@straitlink.in](mailto:support@straitlink.in) before you start."
170
+ "section": "3. Check what works differently",
171
+ "url": "https://straitlink.in/docs/migrate-from-firebase/#3-check-what-works-differently",
172
+ "markdown": "**Do this:** read this table once. Nothing in it needs code today; it tells you what to expect.\n\n| In Dynamic Links | In Strait |\n|---|---|\n| Links on `yourapp.page.link`, owned by Google | Links on your own address, `<handle>.strait.link`. Custom domains are coming soon |\n| Apps made links with an API key | Links are made on your **server** with the secret key. Your app asks your server ([why](https://straitlink.in/docs/keys/)) |\n| `amv`, `imv` (minimum app versions) | Not needed. Handle old app versions in your routing code |\n| `efr` and the preview page | Not needed. We pick the right page per device and per in-app browser |\n| Separate iPad settings | iPads use the iPhone settings |\n| Firebase link stats (`linkStats`) | **Dashboard → Analytics**, and every tap as CSV from [`GET /v1/export`](https://straitlink.in/docs/api/export/) |\n| Firebase Analytics `dynamic_link_first_open` | The SDK reports opens and installs by itself. Add purchases with `trackEvent` |\n\n**You should see:** nothing here blocks your move. If something does, email [support@straitlink.in](mailto:support@straitlink.in) before you start."
172
173
  },
173
174
  {
174
175
  "page": "Migrate from Firebase Dynamic Links",
175
176
  "group": "Get started",
176
- "section": "4. Plan the cutover",
177
- "url": "https://straitlink.in/docs/migrate-from-firebase/#4-plan-the-cutover",
178
- "markdown": "Do the steps in this order, so nothing points at a link that doesn't exist yet:\n\n1. **Workspace and app settings**: package name, scheme, SHA-256 fingerprints, Team ID ([go-live checklist](https://straitlink.in/docs/production-checklist/)).\n2. **Recreate your links**, keeping the old short codes as slugs where you can. The quickest way is one step: enter your old domain and we rebuild the links ([Move your links in one step](https://straitlink.in/docs/migrate-from-firebase/one-step/)); to check each link by hand, use [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/).\n3. **Ship the app update** with the Strait SDK and your new link domain ([Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/)).\n4. **Switch your backend** to `POST /v1/links`, then replace old links in templates and on your website.\n\nGoogle no longer handles `page.link` deep links, so there's no deep-link traffic to move gradually: as soon as the app update is live, new links work end to end. People who already have your app can keep opening it from old `page.link` links if the update keeps the page.link domain: see [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/).\n\n**You should see:** a written order of work, with the app release date as the one date that matters."
177
+ "section": "4. Write down the order",
178
+ "url": "https://straitlink.in/docs/migrate-from-firebase/#4-write-down-the-order",
179
+ "markdown": "**Do this:** do the work in this order, so nothing points at a link that doesn't exist yet.\n\n1. **Settings.** Package name, scheme, SHA-256 fingerprints and Team ID in **Apps & stores** ([go-live checklist](https://straitlink.in/docs/production-checklist/)).\n2. **Links.** Rebuild your old links with their old codes: [Move your links in one step](https://straitlink.in/docs/migrate-from-firebase/one-step/).\n3. **App.** Ship an update with the Strait SDK: [Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/).\n4. **Server.** Switch your backend to `POST /v1/links`, then replace old links in emails and on your website: [Cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/).\n\nPeople who already have your app can keep opening it from old `page.link` links if the update keeps that domain: [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/).\n\n**You should see:** a written list in this order, with your app release date as the one date that matters."
180
+ },
181
+ {
182
+ "page": "Migrate from Firebase Dynamic Links",
183
+ "group": "Get started",
184
+ "section": "Frequently asked questions",
185
+ "url": "https://straitlink.in/docs/migrate-from-firebase/#frequently-asked-questions",
186
+ "markdown": "### Is Firebase Dynamic Links still working?\n\nNo. Google shut it down on 25 August 2025. It no longer opens apps, keeps a link through an install or counts taps, and you can't make new links.\n\n### Can I keep my page.link domain?\n\nNo. `page.link` belongs to Google. You can rebuild your old codes on your own link address, and people who have your app can keep opening it from old links if your next build keeps the domain.\n\n### What does Strait cost after the move?\n\nFree up to 10,000 taps a month, no card. App opens are never billed. Paid plans are on [Pricing](https://straitlink.in/pricing/)."
179
187
  },
180
188
  {
181
189
  "page": "Migrate from Firebase Dynamic Links",
182
190
  "group": "Get started",
183
191
  "section": "Common errors",
184
192
  "url": "https://straitlink.in/docs/migrate-from-firebase/#common-errors",
185
- "markdown": "- **Planning around old `page.link` URLs.** They belong to Google and can't be redirected by anyone else. Replace them wherever you still control the text. For people who already have your app, see [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/).\n- **Putting the secret key in the app** to keep client-side link creation. Create links on your server instead.\n- **Releasing the app before the links exist.** Recreate links first, so the first taps on the new build work."
193
+ "markdown": "- **Trying to redirect old `page.link` URLs.** Nobody but Google can. Replace them wherever you control the text.\n- **Putting the secret key in the app.** Make links on your server instead.\n- **Releasing the app before the links exist.** Rebuild the links first, so the first taps on the new build work."
186
194
  },
187
195
  {
188
196
  "page": "Migrate from Firebase Dynamic Links",
189
197
  "group": "Get started",
190
198
  "section": "Next steps",
191
199
  "url": "https://straitlink.in/docs/migrate-from-firebase/#next-steps",
192
- "markdown": "- [Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/): replace each Dynamic Links call in your app.\n- [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/): recreate your links and switch your backend."
200
+ "markdown": "- [Move your links in one step](https://straitlink.in/docs/migrate-from-firebase/one-step/): type your old domain, we rebuild the links.\n- [Swap the SDK](https://straitlink.in/docs/migrate-from-firebase/sdk/): replace each Dynamic Links call in your app."
193
201
  },
194
202
  {
195
203
  "page": "Move your Firebase links in one step",
196
204
  "group": "Get started",
197
205
  "section": "In plain words",
198
206
  "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#in-plain-words",
199
- "markdown": "You type your old link domain once and press one button. We do the rest in the background:\n\n- **We find your old links.** Nobody can ask Google for a list of a page.link domain's links; no such list exists. So we look your domain up in the Internet Archive, which has saved some public links. If you have your own list, a CSV or an export, add it too: we use every link in it.\n- **We read where each one pointed**, while Google still answers. For links in your file that already have a destination, we don't need Google at all.\n- **We rebuild each link** on Strait with the same code, so an app build that keeps your old domain opens the right place ([how](https://straitlink.in/docs/migrate-from-firebase/page-link/)).\n- **We tell you what's left.** Google has already dropped some links. Nobody can recover those, us included, so we list them with the reason and you type the destination in.\n\nYou can close the page. If it pauses, it picks up again by itself."
207
+ "markdown": "You type your old link domain once and press one button. We do the rest:\n\n- **We find your old links.** Nobody can ask Google for a list of a page.link domain's links; no such list exists. So we look your domain up in the Internet Archive, which has saved some public links. If you have your own list, a CSV or an export, add it too: we use every link in it.\n- **We read where each one pointed**, while Google still answers. For links in your file that already have a destination, we don't need Google at all.\n- **We rebuild each link** on Strait with the same code, so an app build that keeps your old domain opens the right place ([how](https://straitlink.in/docs/migrate-from-firebase/page-link/)).\n- **We tell you what's left.** Google has already dropped some links. Nobody can recover those, us included, so we list them with the reason and you type the destination in.\n\nYou can close the page. If it pauses, it starts again by itself."
200
208
  },
201
209
  {
202
210
  "page": "Move your Firebase links in one step",
203
211
  "group": "Get started",
204
212
  "section": "1. Enter your old domain",
205
213
  "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#1-enter-your-old-domain",
206
- "markdown": "In the dashboard, open **Import → Move from Firebase**. Type your old domain, for example `yourapp.page.link`. If you have a list of old short links, a CSV or a Firebase export, open **Have a list, a CSV or a Firebase export?** and paste or upload it (up to 5 MB). Press **Bring my links over**.\n\n**You should see:** \"Looking for your old links…\", then \"Rebuilding… 12 of 40 done.\" with a progress bar."
214
+ "markdown": "**Do this:** in the dashboard, open **Import → Move from Firebase**. Type your old domain, for example `yourapp.page.link`. Have a list of old links, a CSV or a Firebase export? Open **Have a list, a CSV or a Firebase export?** and paste or upload it (up to 5 MB). Press **Bring my links over**.\n\n**You should see:** \"Looking for your old links…\", then \"Rebuilding… 12 of 40 done.\" with a progress bar."
207
215
  },
208
216
  {
209
217
  "page": "Move your Firebase links in one step",
210
218
  "group": "Get started",
211
219
  "section": "2. Fill in the links that need you",
212
220
  "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#2-fill-in-the-links-that-need-you",
213
- "markdown": "When the bar reaches the end, the **Needs you** list shows each old link that couldn't be rebuilt and why, in plain words. Type a destination next to one and press **Rebuild with this**. For many, press **Download the ones to fill in (CSV)**, add the destinations in a spreadsheet, and upload the file in step 1 again.\n\n**You should see:** \"Done. All 40 old links rebuilt.\" or \"Done. 38 rebuilt, 2 need you.\" Rebuilt links are on your Links page with their old code as the path."
221
+ "markdown": "**Do this:** when the bar reaches the end, look at the **Needs you** list. It shows each old link we couldn't rebuild, and why. Type the web address it should open next to it and press **Rebuild with this**. For many, press **Download the ones to fill in (CSV)**, add the destinations in a spreadsheet, and upload the file in step 1 again.\n\n**You should see:** \"Done. All 40 old links rebuilt.\" or \"Done. 38 rebuilt, 2 need you.\" Rebuilt links are on your Links page with their old code as the path."
214
222
  },
215
223
  {
216
224
  "page": "Move your Firebase links in one step",
217
225
  "group": "Get started",
218
226
  "section": "3. Keep old links opening your app",
219
227
  "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#3-keep-old-links-opening-your-app",
220
- "markdown": "For people who already have your app, keep the page.link domain in your next app build and add it to `linkHosts`: [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/).\n\n**You should see:** on a phone with your new build, tapping `https://yourapp.page.link/<code>` opens the screen the old link opened."
228
+ "markdown": "**Do this:** for people who already have your app, keep the page.link domain in your next app build and add it to `linkHosts`: [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/).\n\n**You should see:** on a phone with your new build, tapping `https://yourapp.page.link/<code>` opens the screen the old link opened."
221
229
  },
222
230
  {
223
231
  "page": "Move your Firebase links in one step",
@@ -231,7 +239,7 @@
231
239
  "group": "Get started",
232
240
  "section": "For developers: what the engine does",
233
241
  "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#for-developers-what-the-engine-does",
234
- "markdown": "**Sources.** Each old link is one item keyed by workspace, host and code. Input can be a list (`https://yourapp.page.link/aBcD`, or bare codes with the domain), a CSV with a short-link column (`short_link`, `short`, `firebase_link`…), an optional destination column and FDL parameter columns (`afl`, `ifl`, `ofl`, `st`, `sd`, `si`, `utm_*`), or JSON: Firebase REST `dynamicLinkInfo` objects or `{ \"shortLink\", \"longLink\" }` pairs. Long links (`/?link=…`) are skipped: the SDK reads them on the phone. Up to 20,000 links per run.\n\n**Discovery.** With a domain, the engine first checks that its `apple-app-site-association` or `assetlinks.json` names your app, then reads the Internet Archive's public index for that host.\n\n**Reading destinations.** For page.link links without a destination, the engine reads Google's Firebase debug page (`?d=1`). That is an undocumented Google format: it works only while Google still answers and Google can change or remove it at any time. A failed read is retried three times. Reads are shared with the page.link recovery: about 1 per second and 500 a day per workspace.\n\n**Rebuilding.** Each link is created with the same rules and safety check as `POST /v1/links`, with `legacyHost` + `legacyCode` set. The path is the old code (`.` and `~` become `-`); if that path is taken, it becomes `code-2`, and the old code still resolves in your app. Fallbacks, preview and `utm_` tags carry over.\n\n**Statuses.** `queued`, `rebuilt` or `needs_attention` with one reason: `google_no_record`, `google_unreadable`, `google_unavailable`, `not_your_app`, `no_destination` (a custom domain link without a destination: only page.link can be read from Google), `bad_destination`, `destination_blocked`, `slug_unavailable` or `error`.\n\n**Re-runs.** Running it again updates the same items; a link already on Strait is marked rebuilt, never made twice. Work runs in small leased batches, so a restart picks up where it stopped.\n\n**Your data.** The progress view and the fill-in CSV show only your workspace's links."
242
+ "markdown": "**Sources.** Each old link is one item keyed by workspace, host and code. Input can be a list (`https://yourapp.page.link/aBcD`, or bare codes with the domain), a CSV with a short-link column (`short_link`, `short`, `firebase_link`…), an optional destination column and FDL parameter columns (`afl`, `ifl`, `ofl`, `st`, `sd`, `si`, `utm_*`), or JSON: Firebase REST `dynamicLinkInfo` objects or `{ \"shortLink\", \"longLink\" }` pairs. Long links (`/?link=…`) are skipped: the SDK reads them on the phone. Up to 20,000 links per run.\n\n**Discovery.** With a domain, the engine first checks that its `apple-app-site-association` or `assetlinks.json` names your app, then reads the Internet Archive's public index for that host.\n\n**Reading destinations.** For page.link links without a destination, the engine reads Google's Firebase debug page (`?d=1`). That is an undocumented Google format: it works only while Google still answers and Google can change or remove it at any time. A failed read is retried three times. Reads are shared with the page.link recovery: about 1 per second and 500 a day per workspace.\n\n**Rebuilding.** Each link is created with the same rules and safety check as `POST /v1/links`, with `legacyHost` + `legacyCode` set. The path is the old code (`.` and `~` become `-`); if that path is taken, it becomes `code-2`, and the old code still resolves in your app. Fallbacks, preview and `utm_` tags carry over.\n\n**Statuses.** `queued`, `rebuilt` or `needs_attention` with one reason: `google_no_record`, `google_unreadable`, `google_unavailable`, `not_your_app`, `no_destination` (a custom domain link without a destination: only page.link can be read from Google), `bad_destination`, `destination_blocked`, `slug_unavailable` or `error`.\n\n**Re-runs.** Running it again updates the same items; a link is never made twice. Work runs in small leased batches, so a restart picks up where it stopped.\n\n**Your data.** The progress view and the fill-in CSV show only your workspace's links."
235
243
  },
236
244
  {
237
245
  "page": "Move your Firebase links in one step",
@@ -252,49 +260,49 @@
252
260
  "group": "Get started",
253
261
  "section": null,
254
262
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/",
255
- "markdown": "Dynamic Links gave you two calls: one for the link that launched the app (and the pending link after an install), and a listener for links while it runs. Strait merges them: you `start()` the client once and get every link in `onLink`, with `kind` set to `direct` or `deferred`.\n\n| Dynamic Links call | Strait SDK |\n|---|---|\n| Android `Firebase.dynamicLinks.getDynamicLink(intent)` | `links.start(intent?.dataString)` in `onCreate`, `links.handleUrl(...)` in `onNewIntent`, then `links.onLink { e -> }` |\n| React Native `dynamicLinks().getInitialLink()` + `dynamicLinks().onLink(fn)` | `links.start()` + `links.onLink(fn)` |\n| Flutter `FirebaseDynamicLinks.instance.getInitialLink()` + `.onLink.listen` | `links.start(initialUrl:, urls:, lifecycle:)` + `links.onLink.listen` |\n| iOS `DynamicLinks.dynamicLinks().handleUniversalLink(url)` | `links.handle(userActivity:)` and `links.handle(url:)` |\n| `pendingDynamicLinkData.link` (a full URL) | `e.path` and `e.params` (already split), or `e.url` |\n| `FirebaseDynamicLinks.getInstance().createDynamicLink()` in the app | A call to your own server, which uses `POST /v1/links` ([example](https://straitlink.in/docs/sdks/react-native/create-links/)) |"
263
+ "markdown": "Only do the step for your kind of app (Android, React Native, Flutter, iOS, Unity or web), then step 6 if your app made links itself. Skip the rest.\n\nDynamic Links gave you two calls: one for the link that opened the app, and a listener for links while it runs. Strait has one: you call `start()` once and get every link in `onLink`. Its `kind` is `direct` (the app was installed) or `deferred` (the link was tapped before the install).\n\n| Dynamic Links call | Strait SDK |\n|---|---|\n| Android `Firebase.dynamicLinks.getDynamicLink(intent)` | `links.start(intent?.dataString)` in `onCreate`, `links.handleUrl(...)` in `onNewIntent`, then `links.onLink { e -> }` |\n| React Native `dynamicLinks().getInitialLink()` + `dynamicLinks().onLink(fn)` | `links.start()` + `links.onLink(fn)` |\n| Flutter `FirebaseDynamicLinks.instance.getInitialLink()` + `.onLink.listen` | `links.start(initialUrl:, urls:, lifecycle:)` + `links.onLink.listen` |\n| iOS `DynamicLinks.dynamicLinks().handleUniversalLink(url)` | `links.handle(userActivity:)` and `links.handle(url:)` |\n| `pendingDynamicLinkData.link` (a full URL) | `e.path` and `e.params` (already split), or `e.url` |\n| `FirebaseDynamicLinks.getInstance().createDynamicLink()` in the app | A call to your own server, which uses `POST /v1/links` ([example](https://straitlink.in/docs/sdks/react-native/create-links/)) |"
256
264
  },
257
265
  {
258
266
  "page": "Migrate from Firebase: swap the SDK",
259
267
  "group": "Get started",
260
268
  "section": "1. Android (Kotlin)",
261
269
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#1-android-kotlin",
262
- "markdown": "Remove `com.google.firebase:firebase-dynamic-links` from `app/build.gradle.kts` and add the Strait SDK ([Install & set up](https://straitlink.in/docs/sdks/android/)). Then replace the listener:\n\n```diff title=\"MainActivity.kt\"\n- Firebase.dynamicLinks.getDynamicLink(intent).addOnSuccessListener { data ->\n- data?.link?.let { openDeepLink(it.path, it.queryParameterNames.associateWith { k -> it.getQueryParameter(k)!! }) }\n- }\n+ links.onLink { e -> runOnUiThread { if (e.matched) openDeepLink(e.path, e.params) } }\n+ if (savedInstanceState == null) links.start(intent?.dataString)\n```\n\n```kotlin title=\"MainActivity.kt\"\noverride fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n intent.dataString?.let(links::handleUrl) // replaces getDynamicLink(intent) for a running app\n}\n```\n\nIn the manifest, add an intent filter for `hilltop.strait.link` (keep the `yourapp.page.link` one if people who have your app should still open it from old links: [how](https://straitlink.in/docs/migrate-from-firebase/page-link/)), and add your custom scheme filter if you didn't have one ([manifest](https://straitlink.in/docs/sdks/android/#4-add-the-intent-filters)).\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"` opens the same screen the old Dynamic Link did."
270
+ "markdown": "**Do this:** remove `com.google.firebase:firebase-dynamic-links` from `app/build.gradle.kts` and add the Strait SDK ([Install & set up](https://straitlink.in/docs/sdks/android/)). Then swap the red lines for the green ones:\n\n```diff title=\"MainActivity.kt\"\n- Firebase.dynamicLinks.getDynamicLink(intent).addOnSuccessListener { data ->\n- data?.link?.let { openDeepLink(it.path, it.queryParameterNames.associateWith { k -> it.getQueryParameter(k)!! }) }\n- }\n+ links.onLink { e -> runOnUiThread { if (e.matched) openDeepLink(e.path, e.params) } }\n+ if (savedInstanceState == null) links.start(intent?.dataString)\n```\n\n```kotlin title=\"MainActivity.kt\"\noverride fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n intent.dataString?.let(links::handleUrl) // replaces getDynamicLink(intent) for a running app\n}\n```\n\nIn the manifest, add an intent filter for `hilltop.strait.link` (keep the `yourapp.page.link` one if people who have your app should still open it from old links: [how](https://straitlink.in/docs/migrate-from-firebase/page-link/)), and add your custom scheme filter if you didn't have one ([manifest](https://straitlink.in/docs/sdks/android/#4-add-the-intent-filters)).\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"` opens the same screen the old Dynamic Link did."
263
271
  },
264
272
  {
265
273
  "page": "Migrate from Firebase: swap the SDK",
266
274
  "group": "Get started",
267
275
  "section": "2. React Native",
268
276
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#2-react-native",
269
- "markdown": "```bash\nnpm uninstall @react-native-firebase/dynamic-links\nnpm install @straitlink/react-native@0.9.1 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n```diff title=\"App.tsx\"\n- import dynamicLinks from '@react-native-firebase/dynamic-links';\n- dynamicLinks().getInitialLink().then((link) => link && handle(link.url));\n- const unsubscribe = dynamicLinks().onLink((link) => handle(link.url));\n+ import { links } from './links'; // createStrait({ publishableKey, endpoint, storage, installReferrer }); links.start();\n+ const unsubscribe = links.onLink((e) => e.matched && e.path && openPath(e.path, e.params));\n```\n\n`links.ts` is on [Install & set up](https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once). `openPath` maps a path to a screen ([Receive links](https://straitlink.in/docs/sdks/react-native/receive-links/)). Expo apps: [Expo (Expo Router)](https://straitlink.in/docs/sdks/expo/).\n\n**You should see:** a tap on `https://hilltop.strait.link/summer` opens the screen, and so does the first open after installing from Google Play."
277
+ "markdown": "**Do this:** in your app's folder, swap the packages, then swap the red lines for the green ones.\n\n```bash\nnpm uninstall @react-native-firebase/dynamic-links\nnpm install @straitlink/react-native@0.9.2 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n```diff title=\"App.tsx\"\n- import dynamicLinks from '@react-native-firebase/dynamic-links';\n- dynamicLinks().getInitialLink().then((link) => link && handle(link.url));\n- const unsubscribe = dynamicLinks().onLink((link) => handle(link.url));\n+ import { links } from './links'; // createStrait({ publishableKey, endpoint, storage, installReferrer }); links.start();\n+ const unsubscribe = links.onLink((e) => e.matched && e.path && openPath(e.path, e.params));\n```\n\n`links.ts` is on [Install & set up](https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once). `openPath` maps a path to a screen ([Receive links](https://straitlink.in/docs/sdks/react-native/receive-links/)). Expo apps: [Expo (Expo Router)](https://straitlink.in/docs/sdks/expo/).\n\n**You should see:** a tap on `https://hilltop.strait.link/summer` opens the screen, and so does the first open after installing from Google Play."
270
278
  },
271
279
  {
272
280
  "page": "Migrate from Firebase: swap the SDK",
273
281
  "group": "Get started",
274
282
  "section": "3. Flutter",
275
283
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#3-flutter",
276
- "markdown": "```bash\nflutter pub remove firebase_dynamic_links\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\nAdd `strait_sdk` as a git dependency ([Install & set up](https://straitlink.in/docs/sdks/flutter/)), then:\n\n```diff title=\"main.dart\"\n- final initial = await FirebaseDynamicLinks.instance.getInitialLink();\n- if (initial != null) route(initial.link);\n- FirebaseDynamicLinks.instance.onLink.listen((data) => route(data.link));\n+ await startLinks(); // links.dart: StraitLinks(...) + links.start(...)\n+ links.onLink.listen((e) { if (e.matched && e.path != null) routePath(e.path!, e.params ?? {}); });\n```\n\n**You should see:** the Flutter console logs `Strait link: direct app_link true /product/42` when you tap `https://hilltop.strait.link/summer`."
284
+ "markdown": "**Do this:** swap the packages, add `strait_sdk` to `pubspec.yaml`, then swap the red lines for the green ones.\n\n```bash\nflutter pub remove firebase_dynamic_links\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n strait_sdk: ^0.9.3\n```\n\n`links.dart` is on [Install & set up](https://straitlink.in/docs/sdks/flutter/).\n\n```diff title=\"main.dart\"\n- final initial = await FirebaseDynamicLinks.instance.getInitialLink();\n- if (initial != null) route(initial.link);\n- FirebaseDynamicLinks.instance.onLink.listen((data) => route(data.link));\n+ await startLinks(); // links.dart: StraitLinks(...) + links.start(...)\n+ links.onLink.listen((e) { if (e.matched && e.path != null) routePath(e.path!, e.params ?? {}); });\n```\n\n**You should see:** the Flutter console logs `Strait link: direct app_link true /product/42` when you tap `https://hilltop.strait.link/summer`."
277
285
  },
278
286
  {
279
287
  "page": "Migrate from Firebase: swap the SDK",
280
288
  "group": "Get started",
281
- "section": "4. iOS (beta)",
282
- "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#4-ios-beta",
283
- "markdown": "Replace the Firebase package with `StraitSDK` from `https://github.com/bazingga08/strait-sdk-swift`, and add `applinks:hilltop.strait.link` to Associated Domains (keep `applinks:yourapp.page.link` for old links: [how](https://straitlink.in/docs/migrate-from-firebase/page-link/)):\n\n```diff title=\"SceneDelegate.swift\"\n func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {\n- guard let url = userActivity.webpageURL else { return }\n- DynamicLinks.dynamicLinks().handleUniversalLink(url) { link, _ in route(link?.url) }\n+ links.handle(userActivity: userActivity) // links.onLink { event in … } routes it\n }\n```\n\nThe full setup, including the launch URL and SwiftUI, is on the [iOS page](https://straitlink.in/docs/sdks/ios/). iPhone support hasn't been proven on a real iPhone yet.\n\n**You should see:** the app builds without `FirebaseDynamicLinks`, and `links.onLink` fires for a Universal Link on your domain."
289
+ "section": "4. iOS",
290
+ "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#4-ios",
291
+ "markdown": "**Do this:** replace the Firebase package with `StraitSDK` from `https://github.com/bazingga08/strait-sdk-swift`, and add `applinks:hilltop.strait.link` to Associated Domains (keep `applinks:yourapp.page.link` for old links: [how](https://straitlink.in/docs/migrate-from-firebase/page-link/)):\n\n```diff title=\"SceneDelegate.swift\"\n func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {\n- guard let url = userActivity.webpageURL else { return }\n- DynamicLinks.dynamicLinks().handleUniversalLink(url) { link, _ in route(link?.url) }\n+ links.handle(userActivity: userActivity) // links.onLink { event in … } routes it\n }\n```\n\nThe full setup, including the launch URL and SwiftUI, is on the [iOS page](https://straitlink.in/docs/sdks/ios/). Universal Links are tested on a real iPhone; install matching after an App Store install is opt-in.\n\n**You should see:** the app builds without `FirebaseDynamicLinks`, and `links.onLink` fires for a Universal Link on your domain."
284
292
  },
285
293
  {
286
294
  "page": "Migrate from Firebase: swap the SDK",
287
295
  "group": "Get started",
288
296
  "section": "5. Unity and the web",
289
297
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#5-unity-and-the-web",
290
- "markdown": "**Unity.** Firebase's Unity plugin raised `DynamicLinks.DynamicLinkReceived`. Strait's plain C# library raises `OnLink`, and Unity's own `Application.deepLinkActivated` feeds it links while the game runs:\n\n```diff title=\"LinksBehaviour.cs\"\n- DynamicLinks.DynamicLinkReceived += (sender, args) => Route(args.ReceivedDynamicLink.Url);\n+ Client.OnLink += e => { if (e.Matched) Route(e.Path, e.Params); };\n+ Application.deepLinkActivated += url => _ = Client.HandleUrl(url);\n+ await Client.Start(Application.absoluteURL); // launch link + deferred check\n```\n\nThe full `LinksBehaviour` is on the [Unity page](https://straitlink.in/docs/sdks/unity/). Its engine glue hasn't been run in the Unity editor yet, so test it on a device before you ship.\n\n**Web.** Dynamic Links sent desktop visitors to the `ofl` URL. Strait does the same with `desktopUrl`, or the destination itself, with no code. If your web app wants the link a visitor tapped on their phone before switching to the browser, the [web SDK](https://straitlink.in/docs/sdks/web/) asks once per browser.\n\n**You should see:** a Unity build logs the link's path on a device, and a desktop browser opening `https://hilltop.strait.link/summer` lands on your website."
298
+ "markdown": "**Do this (Unity):** Firebase's Unity plugin raised `DynamicLinks.DynamicLinkReceived`. Strait's plain C# library raises `OnLink`, and Unity's own `Application.deepLinkActivated` feeds it links while the game runs:\n\n```diff title=\"LinksBehaviour.cs\"\n- DynamicLinks.DynamicLinkReceived += (sender, args) => Route(args.ReceivedDynamicLink.Url);\n+ Client.OnLink += e => { if (e.Matched) Route(e.Path, e.Params); };\n+ Application.deepLinkActivated += url => _ = Client.HandleUrl(url);\n+ await Client.Start(Application.absoluteURL); // launch link + deferred check\n```\n\nThe full `LinksBehaviour` is on the [Unity page](https://straitlink.in/docs/sdks/unity/). Its engine glue hasn't been run in the Unity editor yet, so test it on a device before you ship.\n\n**Web:** nothing to do for most sites. Dynamic Links sent desktop visitors to the `ofl` URL. Strait does the same with `desktopUrl`, or the destination itself, with no code. If your web app wants the link a visitor tapped on their phone before switching to the browser, the [web SDK](https://straitlink.in/docs/sdks/web/) asks once per browser.\n\n**You should see:** a Unity build logs the link's path on a device, and a desktop browser opening `https://hilltop.strait.link/summer` lands on your website."
291
299
  },
292
300
  {
293
301
  "page": "Migrate from Firebase: swap the SDK",
294
302
  "group": "Get started",
295
303
  "section": "6. Replace in-app link creation",
296
304
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#6-replace-in-app-link-creation",
297
- "markdown": "If your app built links with the Dynamic Links builder, move that to your backend, because creating links needs the secret key:\n\n```diff title=\"ShareButton.tsx\"\n- const link = await dynamicLinks().buildShortLink({ link: productUrl, domainUriPrefix: 'https://yourapp.page.link' });\n+ const { url: link } = await (await fetch('https://api.yourapp.com/share', { method: 'POST', body: JSON.stringify({ productId }) })).json();\n```\n\n**You should see:** your share sheet offers `https://hilltop.strait.link/…` links. The server side is on [Create links in the app](https://straitlink.in/docs/sdks/react-native/create-links/)."
305
+ "markdown": "**Do this:** if your app made links with the Dynamic Links builder, move that to your server. Making links needs the secret key, and the secret key must never be in an app.\n\n```diff title=\"ShareButton.tsx\"\n- const link = await dynamicLinks().buildShortLink({ link: productUrl, domainUriPrefix: 'https://yourapp.page.link' });\n+ const { url: link } = await (await fetch('https://api.yourapp.com/share', { method: 'POST', body: JSON.stringify({ productId }) })).json();\n```\n\n**You should see:** your share sheet offers `https://hilltop.strait.link/…` links. The server side is on [Create links in the app](https://straitlink.in/docs/sdks/react-native/create-links/)."
298
306
  },
299
307
  {
300
308
  "page": "Migrate from Firebase: swap the SDK",
@@ -310,47 +318,54 @@
310
318
  "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#next-steps",
311
319
  "markdown": "- [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/): recreate your links and switch your backend.\n- [Test](https://straitlink.in/docs/sdks/react-native/test/): check direct and deferred links on a real phone."
312
320
  },
321
+ {
322
+ "page": "Migrate from Firebase: import links and cut over",
323
+ "group": "Get started",
324
+ "section": null,
325
+ "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/",
326
+ "markdown": "Already used [Move your links in one step](https://straitlink.in/docs/migrate-from-firebase/one-step/)? Your links are rebuilt: skip to step 4."
327
+ },
313
328
  {
314
329
  "page": "Migrate from Firebase: import links and cut over",
315
330
  "group": "Get started",
316
331
  "section": "1. Collect your old links",
317
332
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#1-collect-your-old-links",
318
- "markdown": "Gather them in any of these forms, one per line:\n\n- Firebase short links with the long form attached, like `https://yourapp.page.link/abc?link=https%3A%2F%2Fshop.example.com%2Fp%2F42`\n- Plain destination URLs\n- A CSV with a header row. We recognise `link`, `url`, `destination` or `longUrl` for the destination, and `slug`, `short`, `path` or `key` for the slug.\n\n```txt title=\"links.csv\"\nshort,link\nhttps://yourapp.page.link/summer,https://shop.example.com/product/42\nhttps://yourapp.page.link/invite,https://shop.example.com/invite?ref=abc\n```\n\nNo export? Your backend's database or logs usually hold the `link` values it sent to the shortLinks API.\n\n**You should see:** one file with every link you still want to work."
333
+ "markdown": "**Do this:** put every old link you still want in one file, one per line. Any of these forms works:\n\n- Firebase short links with the long form attached, like `https://yourapp.page.link/abc?link=https%3A%2F%2Fshop.example.com%2Fp%2F42`\n- Plain destination URLs\n- A CSV with a header row. We recognise `link`, `url`, `destination` or `longUrl` for the destination, and `slug`, `short`, `path` or `key` for the slug.\n\n```txt title=\"links.csv\"\nshort,link\nhttps://yourapp.page.link/summer,https://shop.example.com/product/42\nhttps://yourapp.page.link/invite,https://shop.example.com/invite?ref=abc\n```\n\nNo export? Your backend's database or logs usually hold the `link` values it sent to the shortLinks API.\n\n**You should see:** one file with every link you still want to work."
319
334
  },
320
335
  {
321
336
  "page": "Migrate from Firebase: import links and cut over",
322
337
  "group": "Get started",
323
338
  "section": "2. Import them in the dashboard",
324
339
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#2-import-them-in-the-dashboard",
325
- "markdown": "In **Dashboard → Import**, paste the list or upload the CSV. We pull the real destination out of each Firebase link's `link=` parameter, keep the old short code as the slug where we can, and show a preview with any rows that can't be imported (and why) before you confirm.\n\n**You should see:** a preview listing each new `https://hilltop.strait.link/<slug>` link, then the links in **Dashboard → Links**."
340
+ "markdown": "**Do this:** open **Dashboard → Import → Firebase import**. Paste the list, or upload the CSV, and press **Preview**. We pull the real destination out of each Firebase link's `link=` parameter, keep the old short code as the slug where we can, and show a preview with any rows that can't be imported (and why) before you confirm.\n\n**You should see:** a preview listing each new `https://hilltop.strait.link/<slug>` link, then the links in **Dashboard → Links**."
326
341
  },
327
342
  {
328
343
  "page": "Migrate from Firebase: import links and cut over",
329
344
  "group": "Get started",
330
345
  "section": "3. Or import them with the batch API",
331
346
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#3-or-import-them-with-the-batch-api",
332
- "markdown": "For thousands of links, or to keep fallbacks and previews, send up to 500 per call to `POST /v1/links/batch`. Each item takes the same fields as `POST /v1/links`:\n\n```js title=\"import.mjs (Node 18+)\"\nimport { readFileSync } from 'node:fs';\n\nconst rows = readFileSync('links.csv', 'utf8').trim().split('\\n').slice(1).map((l) => l.split(','));\nconst items = rows.map(([short, link]) => ({\n slug: new URL(short).pathname.slice(1), // keep the old short code\n longUrl: link,\n campaign: 'fdl_import',\n}));\n\nfor (let i = 0; i < items.length; i += 500) {\n const res = await fetch('https://strait.link/v1/links/batch', {\n method: 'POST',\n headers: { Authorization: `Bearer ${process.env.SECRET_KEY}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({ links: items.slice(i, i + 500) }),\n });\n const { summary, results } = await res.json();\n console.log(summary, results.filter((r) => !r.ok));\n}\n```\n\nEvery item gets its own result, so one bad row never fails the batch. A slug that already exists is a `409` for that item only, so re-running the script is safe.\n\n**You should see:** `{ total: 2, created: 2, failed: 0 }` for the example file. Failed items show their `status` and `error`."
347
+ "markdown": "**Do this (only for thousands of links):** save this script next to `links.csv` and run `SECRET_KEY=… node import.mjs`. It sends up to 500 links per call to `POST /v1/links/batch`. Each item takes the same fields as `POST /v1/links`:\n\n```js title=\"import.mjs (Node 18+)\"\nimport { readFileSync } from 'node:fs';\n\nconst rows = readFileSync('links.csv', 'utf8').trim().split('\\n').slice(1).map((l) => l.split(','));\nconst items = rows.map(([short, link]) => ({\n slug: new URL(short).pathname.slice(1), // keep the old short code\n longUrl: link,\n campaign: 'fdl_import',\n}));\n\nfor (let i = 0; i < items.length; i += 500) {\n const res = await fetch('https://strait.link/v1/links/batch', {\n method: 'POST',\n headers: { Authorization: `Bearer ${process.env.SECRET_KEY}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({ links: items.slice(i, i + 500) }),\n });\n const { summary, results } = await res.json();\n console.log(summary, results.filter((r) => !r.ok));\n}\n```\n\nEvery item gets its own result, so one bad row never fails the batch. A slug that already exists is a `409` for that item only, so re-running the script is safe.\n\n**You should see:** `{ total: 2, created: 2, failed: 0 }` for the example file. Failed items show their `status` and `error`."
333
348
  },
334
349
  {
335
350
  "page": "Migrate from Firebase: import links and cut over",
336
351
  "group": "Get started",
337
352
  "section": "4. Switch your backend to POST /v1/links",
338
353
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#4-switch-your-backend-to-post-v1links",
339
- "markdown": "Replace the shortLinks call wherever your backend created links:\n\n```diff title=\"share.js\"\n- const res = await fetch(`https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${FIREBASE_API_KEY}`, {\n- method: 'POST',\n- body: JSON.stringify({ dynamicLinkInfo: { domainUriPrefix: 'https://yourapp.page.link', link: productUrl,\n- androidInfo: { androidPackageName: 'com.yourcompany.yourapp' }, socialMetaTagInfo: { socialTitle: title } },\n- suffix: { option: 'SHORT' } }),\n- });\n- const { shortLink } = await res.json();\n+ const res = await fetch('https://strait.link/v1/links', {\n+ method: 'POST',\n+ headers: { Authorization: `Bearer ${process.env.SECRET_KEY}`, 'Content-Type': 'application/json' },\n+ body: JSON.stringify({ slug: `p-${crypto.randomUUID().slice(0, 8)}`, longUrl: productUrl, title }),\n+ });\n+ const { shortUrl: shortLink } = await res.json();\n```\n\nThe same call in Python:\n\n```python title=\"share.py\"\nimport os, uuid, requests\n\nres = requests.post(\n \"https://strait.link/v1/links\",\n headers={\"Authorization\": f\"Bearer {os.environ['SECRET_KEY']}\"},\n json={\"slug\": f\"p-{uuid.uuid4().hex[:8]}\", \"longUrl\": product_url, \"title\": title},\n)\nshort_link = res.json()[\"shortUrl\"]\n```\n\nOr use the [Node SDK](https://straitlink.in/docs/sdks/node/): `await links.createLink({ slug, longUrl, title })`. The package name and bundle ID are no longer sent per link: they're in **Dashboard → Apps & stores**.\n\n**You should see:** your backend returns `https://hilltop.strait.link/…` links, and they appear in **Dashboard → Links**."
354
+ "markdown": "**Do this:** wherever your backend called Firebase's shortLinks API, swap the red lines for the green ones. Keep the secret key in an environment variable (`SECRET_KEY`), never in code.\n\n```diff title=\"share.js\"\n- const res = await fetch(`https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=${FIREBASE_API_KEY}`, {\n- method: 'POST',\n- body: JSON.stringify({ dynamicLinkInfo: { domainUriPrefix: 'https://yourapp.page.link', link: productUrl,\n- androidInfo: { androidPackageName: 'com.yourcompany.yourapp' }, socialMetaTagInfo: { socialTitle: title } },\n- suffix: { option: 'SHORT' } }),\n- });\n- const { shortLink } = await res.json();\n+ const res = await fetch('https://strait.link/v1/links', {\n+ method: 'POST',\n+ headers: { Authorization: `Bearer ${process.env.SECRET_KEY}`, 'Content-Type': 'application/json' },\n+ body: JSON.stringify({ slug: `p-${crypto.randomUUID().slice(0, 8)}`, longUrl: productUrl, title }),\n+ });\n+ const { shortUrl: shortLink } = await res.json();\n```\n\nThe same call in Python:\n\n```python title=\"share.py\"\nimport os, uuid, requests\n\nres = requests.post(\n \"https://strait.link/v1/links\",\n headers={\"Authorization\": f\"Bearer {os.environ['SECRET_KEY']}\"},\n json={\"slug\": f\"p-{uuid.uuid4().hex[:8]}\", \"longUrl\": product_url, \"title\": title},\n)\nshort_link = res.json()[\"shortUrl\"]\n```\n\nOr use the [Node SDK](https://straitlink.in/docs/sdks/node/): `await links.createLink({ slug, longUrl, title })`. The package name and bundle ID are no longer sent per link: they're in **Dashboard → Apps & stores**.\n\n**You should see:** your backend returns `https://hilltop.strait.link/…` links, and they appear in **Dashboard → Links**."
340
355
  },
341
356
  {
342
357
  "page": "Migrate from Firebase: import links and cut over",
343
358
  "group": "Get started",
344
359
  "section": "5. Check the whole path on a real phone",
345
360
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#5-check-the-whole-path-on-a-real-phone",
346
- "markdown": "Before you replace links people can see, check one of each kind:\n\n```bash\n# What a tap does on each device, without recording a tap\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=android\" -H \"Authorization: Bearer $SECRET_KEY\"\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=iphone\" -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\nThen, on a phone with the app update: tap an imported link from Messages or Gmail (the app opens on the right screen), uninstall, tap it again, install from Google Play internal testing and open (the same screen on first open). Paste your domain into the [App Links checker](https://straitlink.in/tools/app-links-checker/) too.\n\n**You should see:** the expected `decision` for each device, the app opening on the right screen, and the tap, open and install in **Analytics**."
361
+ "markdown": "**Do this:** before you change links people can see, paste these two commands. They show what a tap would do on Android and on iPhone, without counting a tap:\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=android\" -H \"Authorization: Bearer $SECRET_KEY\"\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=iphone\" -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\nThen, on a phone with the app update:\n\n1. Tap an imported link in Messages or Gmail. The app opens on the right screen.\n2. Uninstall the app. Tap the link again. Install from Google Play internal testing and open it. The same screen opens. Paste your domain into the [App Links checker](https://straitlink.in/tools/app-links-checker/) too.\n\n**You should see:** the expected `decision` for each device, the app opening on the right screen, and the tap, open and install in **Analytics**."
347
362
  },
348
363
  {
349
364
  "page": "Migrate from Firebase: import links and cut over",
350
365
  "group": "Get started",
351
366
  "section": "6. Replace the links people see",
352
367
  "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#6-replace-the-links-people-see",
353
- "markdown": "Work through the list from [the plan](https://straitlink.in/docs/migrate-from-firebase/#1-list-what-used-dynamic-links): email and push templates, your website, app screens, and SMS templates. For SMS sent in India, whitelist `<handle>.strait.link` in your DLT template. Printed QR codes that point at `page.link` can't be changed; reprint them when you can.\n\n**You should see:** a search of your templates and website for `page.link` finds nothing."
368
+ "markdown": "**Do this:** work through the list from [the plan](https://straitlink.in/docs/migrate-from-firebase/#1-find-everything-that-used-dynamic-links): email and push templates, your website, app screens, and SMS templates. For SMS sent in India, whitelist `<handle>.strait.link` in your DLT template. Printed QR codes that point at `page.link` can't be changed; reprint them when you can.\n\n**You should see:** a search of your templates and website for `page.link` finds nothing."
354
369
  },
355
370
  {
356
371
  "page": "Migrate from Firebase: import links and cut over",
@@ -371,7 +386,7 @@
371
386
  "group": "Get started",
372
387
  "section": "What nobody can do",
373
388
  "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#what-nobody-can-do",
374
- "markdown": "`page.link` belongs to Google. Its DNS, its certificate (only Google's CA may issue one) and every answer it gives are Google's. No provider, us included, can take it over or forward it. Today Google still redirects many old short links to their old destination and returns an error for others, and it can stop either at any time. On 9 October 2026, 21 of 26 real short links we tried still redirected, 5 returned 404, and long links (`/?link=…`) returned 400. Everything below is about people who **already have your app**."
389
+ "markdown": "`page.link` belongs to Google. Its DNS, its certificate and every answer it gives are Google's. No provider, us included, can take it over or forward it. Today Google still redirects many old short links to their old destination and returns an error for others, and it can stop either at any time. On 9 October 2026, 21 of 26 real short links we tried still redirected, 5 returned 404, and long links (`/?link=…`) returned 400. Everything below is about people who **already have your app**."
375
390
  },
376
391
  {
377
392
  "page": "Open your app from old page.link links",
@@ -385,28 +400,28 @@
385
400
  "group": "Get started",
386
401
  "section": "1. Keep the page.link domain in your next build",
387
402
  "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#1-keep-the-pagelink-domain-in-your-next-build",
388
- "markdown": "Do the opposite of removing it. Keep these next to your new `hilltop.strait.link` entries:\n\n- **iOS:** Associated Domains keeps `applinks:yourapp.page.link`.\n- **Android:** the manifest keeps the intent filter for `https://yourapp.page.link` (with `android:autoVerify=\"true\"`, as Firebase had it).\n\n**You should see:** `https://yourapp.page.link/.well-known/assetlinks.json` and `https://yourapp.page.link/apple-app-site-association` still list your package and app ID. If they return 404, Google has stopped serving them for your domain and this page can't help."
403
+ "markdown": "**Do this:** don't remove it. Keep these next to your new `hilltop.strait.link` entries:\n\n- **iOS:** Associated Domains keeps `applinks:yourapp.page.link`.\n- **Android:** the manifest keeps the intent filter for `https://yourapp.page.link` (with `android:autoVerify=\"true\"`, as Firebase had it).\n\n**You should see:** `https://yourapp.page.link/.well-known/assetlinks.json` and `https://yourapp.page.link/apple-app-site-association` still list your package and app ID. If they return 404, Google has stopped serving them for your domain and this page can't help."
389
404
  },
390
405
  {
391
406
  "page": "Open your app from old page.link links",
392
407
  "group": "Get started",
393
408
  "section": "2. Add the host to linkHosts",
394
409
  "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#2-add-the-host-to-linkhosts",
395
- "markdown": "Page.link support is in React Native and Flutter 0.8.2, Android 0.7.3, and Swift and Unity 0.8.1 and later.\n\nReact Native:\n\n```ts\nconst strait = createStrait({\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://<your-handle>.strait.link',\n linkHosts: ['yourapp.page.link'],\n storage: AsyncStorage,\n});\n```\n\nFlutter:\n\n```dart\nfinal strait = StraitLinks(\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://<your-handle>.strait.link',\n linkHosts: const ['yourapp.page.link'],\n // platform, storage… as in the Flutter guide\n);\n```\n\nAndroid (Kotlin):\n\n```kotlin\nstrait = StraitClient(StraitConfig(\n publishableKey = \"st_pub_live_…\",\n endpoint = \"https://<your-handle>.strait.link\",\n linkHosts = listOf(\"yourapp.page.link\"),\n // storage… as in the Android guide\n))\n```\n\nSwift:\n\n```swift\nlet strait = StraitLinks(StraitLinksConfig(\n publishableKey: \"st_pub_live_…\",\n endpoint: \"https://<your-handle>.strait.link\",\n linkHosts: [\"yourapp.page.link\"]\n))\n```\n\nUnity (C#):\n\n```csharp\nClient = new StraitClient(new StraitConfig\n{\n PublishableKey = \"st_pub_live_…\",\n Endpoint = \"https://<your-handle>.strait.link\",\n LinkHosts = { \"yourapp.page.link\" },\n // Storage, Platform… as in the Unity guide\n});\n```\n\nYour `onLink` handler needs no change.\n\n**You should see:** a tap on `https://yourapp.page.link/?link=https://yourapp.com/p/42` (a long link) opens the app on `/p/42` with no lookup."
410
+ "markdown": "**Do this:** add `linkHosts` with your old domain to your app's client. Page.link support is in React Native and Flutter 0.8.2, Android 0.7.3, and Swift and Unity 0.8.1 and later.\n\nReact Native:\n\n```ts\nconst strait = createStrait({\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://<your-handle>.strait.link',\n linkHosts: ['yourapp.page.link'],\n storage: AsyncStorage,\n});\n```\n\nFlutter:\n\n```dart\nfinal strait = StraitLinks(\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://<your-handle>.strait.link',\n linkHosts: const ['yourapp.page.link'],\n // platform, storage… as in the Flutter guide\n);\n```\n\nAndroid (Kotlin):\n\n```kotlin\nstrait = StraitClient(StraitConfig(\n publishableKey = \"st_pub_live_…\",\n endpoint = \"https://<your-handle>.strait.link\",\n linkHosts = listOf(\"yourapp.page.link\"),\n // storage… as in the Android guide\n))\n```\n\nSwift:\n\n```swift\nlet strait = StraitLinks(StraitLinksConfig(\n publishableKey: \"st_pub_live_…\",\n endpoint: \"https://<your-handle>.strait.link\",\n linkHosts: [\"yourapp.page.link\"]\n))\n```\n\nUnity (C#):\n\n```csharp\nClient = new StraitClient(new StraitConfig\n{\n PublishableKey = \"st_pub_live_…\",\n Endpoint = \"https://<your-handle>.strait.link\",\n LinkHosts = { \"yourapp.page.link\" },\n // Storage, Platform… as in the Unity guide\n});\n```\n\nYour `onLink` handler needs no change.\n\n**You should see:** a tap on `https://yourapp.page.link/?link=https://yourapp.com/p/42` (a long link) opens the app on `/p/42` with no lookup."
396
411
  },
397
412
  {
398
413
  "page": "Open your app from old page.link links",
399
414
  "group": "Get started",
400
415
  "section": "3. Import your old codes",
401
416
  "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#3-import-your-old-codes",
402
- "markdown": "In **Dashboard → Import → Firebase**, paste your old links (`https://yourapp.page.link/aBcD?link=…`, or a CSV). Each imported link keeps its **old host and code**. The app's lookup matches host and code first, so `aBcD` from `yourapp.page.link` and `aBcD` from `otherapp.page.link`, or an old code and a new Strait slug, never clash. If the code is already a slug here, the link gets another slug and the old URL still finds it.\n\nThe importer also keeps fallbacks (`afl`, `ifl`, `ofl`), the social preview (`st`, `sd`, `si`) and `utm_*`.\n\n**You should see:** the imported links in **Dashboard → Links**, each showing the page.link URL it came from.\n\n### Don't have the destinations? Recover from page.link\n\nPaste your old page.link codes. While Google still answers a code, we read where it pointed and fill in the new link for you. Codes Google has already dropped can't be recovered by anyone; enter those destinations yourself.\n\nThe **Recover from page.link** tab in the import page reads Google's Firebase debug page (`?d=1`), which is an **undocumented Google format**: it works only while Google still answers and can stop at any time. Before it fetches anything, it checks that your page.link host's `apple-app-site-association` or `assetlinks.json` names your workspace's own app ID or package, so nobody can use it to read another company's links. It runs only when you press the button, at about one request per second."
417
+ "markdown": "**Do this:** in **Dashboard → Import → Firebase import**, paste your old links (`https://yourapp.page.link/aBcD?link=…`, or a CSV). Each imported link keeps its **old host and code**. The app's lookup matches host and code first, so `aBcD` from `yourapp.page.link` and `aBcD` from `otherapp.page.link`, or an old code and a new Strait slug, never clash. If the code is already a slug here, the link gets another slug and the old URL still finds it.\n\nThe importer also keeps fallbacks (`afl`, `ifl`, `ofl`), the social preview (`st`, `sd`, `si`) and `utm_*`.\n\n**You should see:** the imported links in **Dashboard → Links**, each showing the page.link URL it came from.\n\n### Don't have the destinations? Recover from page.link\n\nPaste your old page.link codes. While Google still answers a code, we read where it pointed and fill in the new link for you. Codes Google has already dropped can't be recovered by anyone; enter those destinations yourself.\n\nThe **Recover from page.link** tab in the import page reads Google's Firebase debug page (`?d=1`), which is an **undocumented Google format**: it works only while Google still answers and can stop at any time. Before it fetches anything, it checks that your page.link host's `apple-app-site-association` or `assetlinks.json` names your workspace's own app ID or package, so nobody can use it to read another company's links. It runs only when you press the button, at about one request per second."
403
418
  },
404
419
  {
405
420
  "page": "Open your app from old page.link links",
406
421
  "group": "Get started",
407
422
  "section": "4. Test on your own phones",
408
423
  "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#4-test-on-your-own-phones",
409
- "markdown": "With the new build installed, tap an old short link and an old long link from a chat or email app (not the address bar). The app should open on the right screen. Without the app you get whatever Google serves.\n\n**You should see:** each tap in **Dashboard → Analytics** as an app open."
424
+ "markdown": "**Do this:** with the new build installed, tap an old short link and an old long link from a chat or email app (not the address bar). The app should open on the right screen. Without the app you get whatever Google serves.\n\n**You should see:** each tap in **Dashboard → Analytics** as an app open."
410
425
  },
411
426
  {
412
427
  "page": "Open your app from old page.link links",
@@ -425,51 +440,65 @@
425
440
  {
426
441
  "page": "Migrate from Branch",
427
442
  "group": "Get started",
428
- "section": "What maps to what",
429
- "url": "https://straitlink.in/docs/migrate-from-branch/#what-maps-to-what",
430
- "markdown": "| Branch | Strait |\n|---|---|\n| `yourapp.app.link` domain (or your Branch custom domain) | Your workspace link domain, `<handle>.strait.link`. Custom domains are coming soon |\n| Link alias / short path | The link's slug |\n| `$deeplink_path` (what the app routes on) | The path and query of the link's destination (`longUrl`). The SDK hands your app `e.path` and `e.params` |\n| `$canonical_url`, `$fallback_url`, `$desktop_url` | `longUrl` / `desktopUrl` |\n| `$ios_url`, `$android_url` | `iosFallbackUrl`, `androidFallbackUrl` |\n| `$og_title`, `$og_description`, `$og_image_url` | `title`, `description`, `imageUrl` |\n| `~campaign` | `campaign` |\n| `~channel` | `utm_source` on the destination (that's where Strait reads a link's channel from) |\n| `~tags`, `~feature` | `tags` (the feature becomes a `feature:<name>` tag) |\n| `Branch.getAutoInstance()` / `initSession` callback | The SDK's `onLink` callback (direct and deferred) |\n| `BranchEvent` / custom events | `trackEvent` |"
443
+ "section": null,
444
+ "url": "https://straitlink.in/docs/migrate-from-branch/",
445
+ "markdown": "Five steps. Each says what to do, what to copy and how you know it worked."
431
446
  },
432
447
  {
433
448
  "page": "Migrate from Branch",
434
449
  "group": "Get started",
435
450
  "section": "1. Export your links from Branch",
436
451
  "url": "https://straitlink.in/docs/migrate-from-branch/#1-export-your-links-from-branch",
437
- "markdown": "Download your links from your Branch dashboard's link export, as CSV or JSON. The first row of a CSV must be the header row with Branch's field names (`url`, `alias`, `$canonical_url`, `$deeplink_path`, `~campaign` and so on). JSON can be a list of links, `{ \"links\": [ … ] }`, or one link per line, and link data can sit at the top level or under `data` the way Branch's API returns it.\n\n```txt title=\"branch-links.csv (shortened)\"\nurl,$canonical_url,$deeplink_path,$ios_url,$android_url,$og_title,~campaign,~channel,~tags\nhttps://yourapp.app.link/summer-sale,https://shop.example.com/sale/summer,,,,Summer Sale,summer_2026,instagram,\"sale,summer\"\nhttps://yourapp.app.link/p42,https://shop.example.com/products/42,product/42,https://shop.example.com/ios/42,https://shop.example.com/android/42,,,,\n```"
452
+ "markdown": "**Do this:** in your Branch dashboard, download your link export as CSV or JSON. Don't edit it. A CSV's first row must be Branch's own column names (`url`, `alias`, `$canonical_url`, `$deeplink_path`, `~campaign` and so on).\n\n```txt title=\"branch-links.csv (shortened)\"\nurl,$canonical_url,$deeplink_path,$ios_url,$android_url,$og_title,~campaign,~channel,~tags\nhttps://yourapp.app.link/summer-sale,https://shop.example.com/sale/summer,,,,Summer Sale,summer_2026,instagram,\"sale,summer\"\nhttps://yourapp.app.link/p42,https://shop.example.com/products/42,product/42,https://shop.example.com/ios/42,https://shop.example.com/android/42,,,,\n```\n\n**You should see:** one file with a row (or JSON entry) per Branch link."
438
453
  },
439
454
  {
440
455
  "page": "Migrate from Branch",
441
456
  "group": "Get started",
442
- "section": "2. How each field is mapped",
443
- "url": "https://straitlink.in/docs/migrate-from-branch/#2-how-each-field-is-mapped",
444
- "markdown": "**Slug.** The link's `alias` if it has one, else the last part of the Branch short link (`https://yourapp.app.link/summer-sale` becomes `summer-sale`), else the Branch link id (`~id`). Slugs can contain letters, numbers, `-` and `_` (up to 64), so other characters become `-`: `Invite%20Friend` becomes `Invite-Friend`. If that makes two different Branch links land on the same slug, the second gets `-2`. A slug that appears twice in the file, or that a live link in your workspace already uses, is shown as a problem and not imported.\n\n**Destination and deep link path.** Strait routes your app by the destination's path and query: a tap on a link whose `longUrl` is `https://shop.example.com/product/42?ref=x` gives your app `e.path = \"/product/42\"` and `e.params = { ref: \"x\" }`. So:\n\n- **No `$deeplink_path`:** `longUrl` is `$canonical_url`, else `$fallback_url`, else `$desktop_url`.\n- **With a `$deeplink_path`:** `longUrl` is the web URL's origin plus the deep link path. With `$canonical_url = https://shop.example.com/products/42` and `$deeplink_path = product/42`, `longUrl` is `https://shop.example.com/product/42`, and the app gets `/product/42`. A custom-scheme path loses its scheme: `myapp://invite?code=XYZ` becomes `/invite?code=XYZ`. If a link has a deep link path but no web URL at all, enter your website in the import screen and it is used as the origin.\n- **Different `$ios_deeplink_path` and `$android_deeplink_path`:** Strait has one in-app path for both platforms, so the Android path is used and the row shows a warning. Make sure both apps handle it.\n\nBecause the deep link path may not be a real page on your website, the original web page is kept for people without the app: `desktopUrl` becomes the canonical/fallback URL (unless `$desktop_url` is set), and so do `iosFallbackUrl` and `androidFallbackUrl` (unless `$ios_url` / `$android_url` are set).\n\n**Platform fallbacks.** `$ios_url` → `iosFallbackUrl` and `$android_url` → `androidFallbackUrl`: where someone without the app goes on that platform when your workspace sends them to a website rather than the store.\n\n**Social previews.** `$og_title` → `title` (or `$marketing_title` when there's no `$og_title`), `$og_description` → `description`, `$og_image_url` → `imageUrl`.\n\n**Campaigns.** `~campaign` → `campaign`. `~channel` is added to the destination as `utm_source` (unless it already has one), which is how Strait labels a link's channel in [Analytics](https://straitlink.in/docs/analytics/). `~tags` become tags, and `~feature` adds a `feature:<name>` tag.\n\n**Everything must be https.** A destination that isn't `https://` stops the row; an optional URL that isn't (for example an `http://` preview image) is left out and the row shows a warning.\n\n**Other fields are not imported.** The preview lists every field in your file that Strait doesn't use, such as `~stage`, `~creation_source`, `$one_time_use` and your own custom link data keys. If your app reads custom link data, put what it needs into the deep link path or its query string before you import."
457
+ "section": "2. Preview the import",
458
+ "url": "https://straitlink.in/docs/migrate-from-branch/#2-preview-the-import",
459
+ "markdown": "**Do this:** open **Dashboard → Import → From Branch**. Choose the file and press **Preview**.\n\nEach row shows the new short path, the screen your app will open, fallbacks, preview, campaign and tags. This is how fields move:\n\n| Branch | Strait |\n|---|---|\n| `alias`, or the end of the short link (`yourapp.app.link/summer-sale` → `summer-sale`) | The short path (slug). Letters, numbers, `-` and `_`, up to 64; other characters become `-` |\n| `$deeplink_path` (what your app routes on) | The path your app gets, `e.path`. `product/42` becomes `/product/42` on your website's address |\n| `$canonical_url`, `$fallback_url`, `$desktop_url` | Where people without the app, and computers, go |\n| `$ios_url`, `$android_url` | `iosFallbackUrl`, `androidFallbackUrl` |\n| `$og_title` (or `$marketing_title`), `$og_description`, `$og_image_url` | The chat preview: `title`, `description`, `imageUrl` |\n| `~campaign` | `campaign` |\n| `~channel` | `utm_source` on the destination (how Analytics names the channel) |\n| `~tags`, `~feature` | Tags (the feature becomes a `feature:<name>` tag) |\n\nRows with a problem say why in plain words. Every field we don't use (like `~stage`, `$one_time_use` or your own custom keys) is listed too.\n\n**You should see:** a table of your links. Any row with a problem says what's wrong."
445
460
  },
446
461
  {
447
462
  "page": "Migrate from Branch",
448
463
  "group": "Get started",
449
464
  "section": "3. Import",
450
465
  "url": "https://straitlink.in/docs/migrate-from-branch/#3-import",
451
- "markdown": "### In the dashboard\n\nOpen **Dashboard → Import → From Branch**, choose the export file and select **Preview**. Each row shows the slug, the destination and the path your app will get, the fallbacks, the social preview, the campaign and tags, and any problems. Select **Import**. You get a summary of what was created and what wasn't (with the reason), and you can download the rows that failed as a CSV, fix them and import them again with **Bulk create**.\n\nImports are safe to run twice: a slug that already exists is reported, never overwritten.\n\n### With the API\n\n`POST /v1/links/batch` creates up to 500 links per call with your secret key. Each item takes the same fields as `POST /v1/links` and gets its own result, so one bad item never fails the batch. A slug that already exists (or repeats earlier in the batch) is a `409` for that item only.\n\n```bash\ncurl https://strait.link/v1/links/batch \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"links\": [\n {\n \"slug\": \"p42\",\n \"longUrl\": \"https://shop.example.com/product/42\",\n \"desktopUrl\": \"https://shop.example.com/products/42\",\n \"iosFallbackUrl\": \"https://shop.example.com/ios/42\",\n \"androidFallbackUrl\": \"https://shop.example.com/android/42\",\n \"title\": \"Product 42\",\n \"campaign\": \"summer_2026\",\n \"tags\": [\"sale\"]\n },\n { \"slug\": \"summer-sale\", \"longUrl\": \"https://shop.example.com/sale/summer?utm_source=instagram\" }\n ]\n }'\n```\n\n```json\n{\n \"results\": [\n { \"index\": 0, \"ok\": true, \"status\": 201, \"link\": { \"slug\": \"p42\", \"shortUrl\": \"https://<handle>.strait.link/p42\", … } },\n { \"index\": 1, \"ok\": false, \"status\": 409, \"error\": \"slug already exists in this workspace\" }\n ],\n \"summary\": { \"total\": 2, \"created\": 1, \"failed\": 1 }\n}\n```\n\nEach item's `status` is what `POST /v1/links` would have answered: `201`, `400` (invalid, or `destination_blocked`), `402` (plan limit reached) or `409`. See the [REST API](https://straitlink.in/docs/api/#batch-create)."
466
+ "markdown": "**Do this:** press **Import N links**.\n\nRunning it twice is safe: a short path that already exists is reported, never overwritten. Rows that failed can be downloaded as a CSV, fixed, and imported again with **Bulk create (CSV)**.\n\nFor thousands of links, use the API instead: `POST /v1/links/batch` takes up to 500 links per call with your **secret key** (the server-only key from **Dashboard → Settings**).\n\n```bash\ncurl https://strait.link/v1/links/batch \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"links\": [\n {\n \"slug\": \"p42\",\n \"longUrl\": \"https://shop.example.com/product/42\",\n \"desktopUrl\": \"https://shop.example.com/products/42\",\n \"iosFallbackUrl\": \"https://shop.example.com/ios/42\",\n \"androidFallbackUrl\": \"https://shop.example.com/android/42\",\n \"title\": \"Product 42\",\n \"campaign\": \"summer_2026\",\n \"tags\": [\"sale\"]\n },\n { \"slug\": \"summer-sale\", \"longUrl\": \"https://shop.example.com/sale/summer?utm_source=instagram\" }\n ]\n }'\n```\n\n```json\n{\n \"results\": [\n { \"index\": 0, \"ok\": true, \"status\": 201, \"link\": { \"slug\": \"p42\", \"shortUrl\": \"https://<handle>.strait.link/p42\", … } },\n { \"index\": 1, \"ok\": false, \"status\": 409, \"error\": \"slug already exists in this workspace\" }\n ],\n \"summary\": { \"total\": 2, \"created\": 1, \"failed\": 1 }\n}\n```\n\nEach item gets its own answer: `201` made, `400` invalid, `402` plan limit reached, `409` short path taken. See the [REST API](https://straitlink.in/docs/api/#batch-create).\n\n**You should see:** a summary of what was made, and your links in **Dashboard → Links** at `https://<handle>.strait.link/<path>`."
467
+ },
468
+ {
469
+ "page": "Migrate from Branch",
470
+ "group": "Get started",
471
+ "section": "4. Swap the SDK in your app",
472
+ "url": "https://straitlink.in/docs/migrate-from-branch/#4-swap-the-sdk-in-your-app",
473
+ "markdown": "**Do this:** remove the Branch SDK and add ours ([React Native](https://straitlink.in/docs/sdks/react-native/), [Flutter](https://straitlink.in/docs/sdks/flutter/), [Android](https://straitlink.in/docs/sdks/android/), [iOS](https://straitlink.in/docs/sdks/ios/), [Unity](https://straitlink.in/docs/sdks/unity/)). Then swap the red lines for the green ones:\n\n```diff\n- branch.subscribe(({ params }) => { if (params['+clicked_branch_link']) route(params.$deeplink_path); });\n+ links.onLink((e) => { if (e.matched && e.path) route(e.path, e.params); });\n```\n\n```diff\n- new BranchEvent(BranchEvent.Purchase, null, { revenue: 49.99, currency: 'USD' }).logEvent();\n+ links.trackEvent('purchase', { value: 49.99, currency: 'USD' });\n```\n\nOn native Android and iOS it's the same idea: drop `Branch.getAutoInstance()` and `initSession`, start the Strait client at launch, and listen with `onLink`. Links tapped before an install arrive in the same callback with `kind: 'deferred'`.\n\nLast, point app verification at your new address. Android: set the intent filter's `android:host` to `hilltop.strait.link` and add your SHA-256 fingerprints in **Apps & stores**. iPhone: set Associated Domains to `applinks:hilltop.strait.link` and add your Team ID and bundle ID in **Apps & stores** ([how](https://straitlink.in/docs/app-links/)).\n\n**You should see:** tapping `https://hilltop.strait.link/p42` in Messages or Gmail opens your app on the product 42 screen."
452
474
  },
453
475
  {
454
476
  "page": "Migrate from Branch",
455
477
  "group": "Get started",
456
- "section": "4. Swap the SDK",
457
- "url": "https://straitlink.in/docs/migrate-from-branch/#4-swap-the-sdk",
458
- "markdown": "Remove the Branch SDK and add ours ([React Native](https://straitlink.in/docs/sdks/react-native/), [Flutter](https://straitlink.in/docs/sdks/flutter/), [Android](https://straitlink.in/docs/sdks/android/), [iOS](https://straitlink.in/docs/sdks/ios/), [Unity](https://straitlink.in/docs/sdks/unity/)). Where you read `$deeplink_path` from Branch's link data, route on `e.path` and `e.params` instead; the import put the deep link path there.\n\n```diff\n- branch.subscribe(({ params }) => { if (params['+clicked_branch_link']) route(params.$deeplink_path); });\n+ links.onLink((e) => { if (e.matched && e.path) route(e.path, e.params); });\n```\n\nOn Android and iOS that's the same change: drop `Branch.getAutoInstance()` and the `initSession` callback, start the Strait client at launch, and subscribe to `onLink`. Deferred links (the link someone tapped before installing) arrive through the same callback with `kind` set to `deferred`.\n\nEvents move from Branch events to `trackEvent`:\n\n```diff\n- new BranchEvent(BranchEvent.Purchase, null, { revenue: 49.99, currency: 'USD' }).logEvent();\n+ links.trackEvent('purchase', { value: 49.99, currency: 'USD' });\n```\n\nThen update app verification to your link domain: on Android change the intent filter's `android:host` to `hilltop.strait.link` and add your SHA-256 fingerprints in **Apps & stores**; on iPhone change Associated Domains to `applinks:hilltop.strait.link` and add your Team ID and bundle ID in **Apps & stores**. See [App Links & Universal Links](https://straitlink.in/docs/app-links/)."
478
+ "section": "5. Switch over, then retire Branch",
479
+ "url": "https://straitlink.in/docs/migrate-from-branch/#5-switch-over-then-retire-branch",
480
+ "markdown": "**Do this:** go in this order.\n\n1. **Ship the app update.** You can keep the Branch SDK in this release, so old Branch links still open the app.\n2. **Make new links on Strait.** Point your backend, marketing tools and share buttons at `POST /v1/links` (or the [Node SDK](https://straitlink.in/docs/sdks/node/)).\n3. **Replace old links** wherever you control the text: website, emails, app screens, QR codes you can reprint.\n4. **Keep Branch active** while old `app.link` links still get taps. Links on `app.link` work only while your Branch account is active.\n5. **Remove the Branch SDK** in a later release, once taps on old links have stopped.\n\n**You should see:** new taps in **Dashboard → Analytics**, and old Branch links getting fewer taps each week."
459
481
  },
460
482
  {
461
483
  "page": "Migrate from Branch",
462
484
  "group": "Get started",
463
- "section": "5. Cutover plan",
464
- "url": "https://straitlink.in/docs/migrate-from-branch/#5-cutover-plan",
465
- "markdown": "1. **Import and test.** Import your links, then tap a few on a real phone with the app installed and without it, and on a desktop browser.\n2. **Ship the app update** with the Strait SDK and the new verification domain. You can keep the Branch SDK in the same release so old Branch links still open the app for people on it.\n3. **Create new links on Strait.** Point your backend, marketing tools and share buttons at `POST /v1/links` (or the [Node SDK](https://straitlink.in/docs/sdks/node/)). New links live on `<handle>.strait.link`.\n4. **Keep Branch active while old links circulate.** Links on `app.link` are served by Branch, and only while your Branch account is active. Replace them wherever you still control the text (website, emails, app screens, QR codes you can reprint), then let the rest age out before you close the Branch account.\n5. **Remove the Branch SDK** in a later app release, once taps on old links have dropped off.\n\nIf you used a custom domain with Branch, it can't move to Strait yet: custom domains are coming soon. Until then, new links use `<handle>.strait.link`."
485
+ "section": "Frequently asked questions",
486
+ "url": "https://straitlink.in/docs/migrate-from-branch/#frequently-asked-questions",
487
+ "markdown": "### What doesn't move from Branch?\n\nAnalytics history: Branch's clicks, installs and attribution stay in Branch, so export the reports you need first. Also not moved: the `app.link` domain (Branch owns it), custom link data beyond the path and query, and settings like `$one_time_use`, journeys and banners. Separate iOS and Android deep link paths become one path; the Android one is kept and the row warns you.\n\n### Can I bring my Branch custom domain?\n\nNot yet. Custom domains are coming soon. Until then, new links use `<handle>.strait.link`.\n\n### What does Strait cost?\n\nFree up to 10,000 taps a month, no card. App opens are never billed. Paid plans are on [Pricing](https://straitlink.in/pricing/)."
466
488
  },
467
489
  {
468
490
  "page": "Migrate from Branch",
469
491
  "group": "Get started",
470
- "section": "What doesn't migrate",
471
- "url": "https://straitlink.in/docs/migrate-from-branch/#what-doesnt-migrate",
472
- "markdown": "- **Analytics history.** Branch's clicks, installs, opens and attribution data stay in Branch. Export the reports you need before you close the account. Strait counts from the day your links and app switch over.\n- **The `app.link` domain.** It belongs to Branch. Links on it keep working only while your Branch account is active, and nobody else can redirect them.\n- **A Branch custom domain.** Not yet: custom domains are coming soon.\n- **Custom link data** beyond the deep link path and query, and Branch settings such as `$one_time_use`, `$match_duration` or journeys and banners. The import lists every field it leaves out.\n- **Separate iOS and Android deep link paths.** One path is kept for both."
492
+ "section": "Common errors",
493
+ "url": "https://straitlink.in/docs/migrate-from-branch/#common-errors",
494
+ "markdown": "- **A row says the destination isn't https.** Every destination must start with `https://`. Fix it in the file and preview again.\n- **A short path is reported as taken.** Another link in your workspace already uses it. Rename it in the file, or keep the existing link.\n- **The app opens the website, not the app.** The new build with `hilltop.strait.link` isn't installed, or fingerprints or Team ID are missing in **Apps & stores**."
495
+ },
496
+ {
497
+ "page": "Migrate from Branch",
498
+ "group": "Get started",
499
+ "section": "Next steps",
500
+ "url": "https://straitlink.in/docs/migrate-from-branch/#next-steps",
501
+ "markdown": "- [Test](https://straitlink.in/docs/sdks/react-native/test/): check direct and deferred links on a real phone.\n- [Analytics: how we count](https://straitlink.in/docs/analytics/): how taps, opens and installs compare with Branch's numbers."
473
502
  },
474
503
  {
475
504
  "page": "Deferred deep links in React Native, end to end",
@@ -490,7 +519,7 @@
490
519
  "group": "Recipes",
491
520
  "section": "2. Install the SDK with the Play Install Referrer",
492
521
  "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#2-install-the-sdk-with-the-play-install-referrer",
493
- "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The SDK reads it for you once you pass the referrer module:\n\n```bash\nnpm install @straitlink/react-native@0.9.1 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n```ts title=\"links.ts\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started\n endpoint: 'https://hilltop.strait.link', // your link domain\n storage: AsyncStorage, // keeps the once-per-install flag\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.start();\n```\n\nImport `./links` once from `index.js`, then rebuild with `npx react-native run-android` (the SDK adds a native module). Add the intent filters from [Install & set up, step 3](https://straitlink.in/docs/sdks/react-native/#3-let-the-phone-hand-links-to-your-app).\n\n**You should see:** the app starts as before, and `@straitlink/react-native` is in `package.json`."
522
+ "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The SDK reads it for you once you pass the referrer module:\n\n```bash\nnpm install @straitlink/react-native@0.9.2 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n```ts title=\"links.ts\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started\n endpoint: 'https://hilltop.strait.link', // your link domain\n storage: AsyncStorage, // keeps the once-per-install flag\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.start();\n```\n\nImport `./links` once from `index.js`, then rebuild with `npx react-native run-android` (the SDK adds a native module). Add the intent filters from [Install & set up, step 3](https://straitlink.in/docs/sdks/react-native/#3-let-the-phone-hand-links-to-your-app).\n\n**You should see:** the app starts as before, and `@straitlink/react-native` is in `package.json`."
494
523
  },
495
524
  {
496
525
  "page": "Deferred deep links in React Native, end to end",
@@ -539,7 +568,7 @@
539
568
  "group": "Recipes",
540
569
  "section": "Next steps",
541
570
  "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#next-steps",
542
- "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone (beta) and keys before real users tap.\n- [Test on a real phone](https://straitlink.in/docs/sdks/react-native/test/): deep links, deferred links from Google Play and analytics, one check at a time."
571
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone and keys before real users tap.\n- [Test on a real phone](https://straitlink.in/docs/sdks/react-native/test/): deep links, deferred links from Google Play and analytics, one check at a time."
543
572
  },
544
573
  {
545
574
  "page": "Deferred deep links in Expo, end to end",
@@ -560,7 +589,7 @@
560
589
  "group": "Recipes",
561
590
  "section": "2. Install the SDK and add app.json settings",
562
591
  "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#2-install-the-sdk-and-add-appjson-settings",
563
- "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The SDK reads it once you pass the referrer module:\n\n```bash\nnpm install @straitlink/react-native@0.9.1\nnpx expo install @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\nCreate `links.ts` exactly as for [bare React Native](https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once): its `installReferrer` line is what carries the link through Google Play. Add `scheme`, `android.package`, the `intentFilters` and `associatedDomains` to `app.json` as in [Expo, step 2](https://straitlink.in/docs/sdks/expo/#2-add-link-settings-to-appjson).\n\n**You should see:** `npx expo config --type public` prints your `scheme` and `intentFilters`, and the SDK is in `package.json`."
592
+ "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The SDK reads it once you pass the referrer module:\n\n```bash\nnpm install @straitlink/react-native@0.9.2\nnpx expo install @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\nCreate `links.ts` exactly as for [bare React Native](https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once): its `installReferrer` line is what carries the link through Google Play. Add `scheme`, `android.package`, the `intentFilters` and `associatedDomains` to `app.json` as in [Expo, step 2](https://straitlink.in/docs/sdks/expo/#2-add-link-settings-to-appjson).\n\n**You should see:** `npx expo config --type public` prints your `scheme` and `intentFilters`, and the SDK is in `package.json`."
564
593
  },
565
594
  {
566
595
  "page": "Deferred deep links in Expo, end to end",
@@ -609,7 +638,7 @@
609
638
  "group": "Recipes",
610
639
  "section": "Next steps",
611
640
  "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#next-steps",
612
- "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone (beta) and keys before real users tap.\n- [Test mode (coming soon)](https://straitlink.in/docs/test-mode/): check the deferred wiring from a debug build, without a Play upload."
641
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone and keys before real users tap.\n- [Test mode (coming soon)](https://straitlink.in/docs/test-mode/): check the deferred wiring from a debug build, without a Play upload."
613
642
  },
614
643
  {
615
644
  "page": "Deferred deep links in Flutter, end to end",
@@ -630,7 +659,7 @@
630
659
  "group": "Recipes",
631
660
  "section": "2. Add the package and the Play Install Referrer",
632
661
  "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#2-add-the-package-and-the-play-install-referrer",
633
- "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The package reads it once you pass `play_install_referrer` in. Add the git dependency:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n strait_sdk: ^0.9.2\n```\n\n```bash\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\nCreate `lib/links.dart` exactly as in [Install & set up, step 2](https://straitlink.in/docs/sdks/flutter/#2-create-the-client-once): its `installReferrer` line is what carries the link through Google Play. Add the intent filters and `flutter_deeplinking_enabled` from [step 3](https://straitlink.in/docs/sdks/flutter/#3-let-the-phone-hand-links-to-your-app).\n\n**You should see:** `flutter pub get` finishes, and the app starts as before after a full `flutter run` (not a hot restart: the plugins are native)."
662
+ "markdown": "On Android, the link travels through the install inside the Google Play Install Referrer. The package reads it once you pass `play_install_referrer` in. Add the package from pub.dev:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n strait_sdk: ^0.9.3\n```\n\n```bash\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\nCreate `lib/links.dart` exactly as in [Install & set up, step 2](https://straitlink.in/docs/sdks/flutter/#2-create-the-client-once): its `installReferrer` line is what carries the link through Google Play. Add the intent filters and `flutter_deeplinking_enabled` from [step 3](https://straitlink.in/docs/sdks/flutter/#3-let-the-phone-hand-links-to-your-app).\n\n**You should see:** `flutter pub get` finishes, and the app starts as before after a full `flutter run` (not a hot restart: the plugins are native)."
634
663
  },
635
664
  {
636
665
  "page": "Deferred deep links in Flutter, end to end",
@@ -679,14 +708,14 @@
679
708
  "group": "Recipes",
680
709
  "section": "Next steps",
681
710
  "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#next-steps",
682
- "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone (beta) and keys before real users tap.\n- [Test mode (coming soon)](https://straitlink.in/docs/test-mode/): check the deferred wiring from a debug build, without a Play upload."
711
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): verified links, iPhone and keys before real users tap.\n- [Test mode (coming soon)](https://straitlink.in/docs/test-mode/): check the deferred wiring from a debug build, without a Play upload."
683
712
  },
684
713
  {
685
714
  "page": "Platforms and SDKs",
686
715
  "group": "Platforms",
687
716
  "section": "App SDKs",
688
717
  "url": "https://straitlink.in/docs/sdks/#app-sdks",
689
- "markdown": "| Platform | Package | What it covers | Start here |\n|---|---|---|---|\n| React Native | `@straitlink/react-native` | Links, deferred links (Play Install Referrer on Android), open reporting | [Install & set up](https://straitlink.in/docs/sdks/react-native/) |\n| Expo (Expo Router) | `@straitlink/react-native` | The same SDK, set up through `app.json`. Needs a development build | [Expo](https://straitlink.in/docs/sdks/expo/) |\n| Flutter | `strait_sdk` | The same, in pure Dart | [Install & set up](https://straitlink.in/docs/sdks/flutter/) |\n| Android (Kotlin) | `strait-sdk-android` | Native Android apps | [Install & set up](https://straitlink.in/docs/sdks/android/) |\n| iOS (Swift) | `StraitSDK` | Native iOS apps (beta) | [iOS](https://straitlink.in/docs/sdks/ios/) |\n| Unity (C#) | `strait-sdk-unity` | Games on Android and iOS | [Unity](https://straitlink.in/docs/sdks/unity/) |\n\nReact Native, Flutter and Android each have the same pages: **Install & set up**, **Receive links**, **Create links in the app**, **Events & revenue**, **Test** and **Reference**."
718
+ "markdown": "| Platform | Package | What it covers | Start here |\n|---|---|---|---|\n| React Native | `@straitlink/react-native` | Links, deferred links (Play Install Referrer on Android), open reporting | [Install & set up](https://straitlink.in/docs/sdks/react-native/) |\n| Expo (Expo Router) | `@straitlink/react-native` | The same SDK, set up through `app.json`. Needs a development build | [Expo](https://straitlink.in/docs/sdks/expo/) |\n| Flutter | `strait_sdk` | The same, in pure Dart | [Install & set up](https://straitlink.in/docs/sdks/flutter/) |\n| Android (Kotlin) | `strait-sdk-android` | Native Android apps | [Install & set up](https://straitlink.in/docs/sdks/android/) |\n| iOS (Swift) | `StraitSDK` | Native iOS apps | [iOS](https://straitlink.in/docs/sdks/ios/) |\n| Unity (C#) | `strait-sdk-unity` | Games on Android and iOS | [Unity](https://straitlink.in/docs/sdks/unity/) |\n\nReact Native, Flutter and Android each have the same pages: **Install & set up**, **Receive links**, **Create links in the app**, **Events & revenue**, **Test** and **Reference**."
690
719
  },
691
720
  {
692
721
  "page": "Platforms and SDKs",
@@ -714,7 +743,7 @@
714
743
  "group": "Platforms",
715
744
  "section": "What's tested",
716
745
  "url": "https://straitlink.in/docs/sdks/#whats-tested",
717
- "markdown": "The iOS (Swift) SDK is at version 0.9.2, the Unity SDK at 0.8.5, the React Native SDK at 0.9.1, the Flutter SDK at 0.9.2, the Android SDK at 0.8.0, the Node SDK at 0.6.4 and the web SDK at 0.6.2. Each one's core is checked against the same shared test files: the same link events, app-state labels and offline retry. Two limits today:\n\n- **iOS** is in beta: covered by automated tests, not yet proven on a real iPhone.\n- **Unity**: the C# library is tested, but the Unity glue in the guide (MonoBehaviour, PlayerPrefs, the iOS plugin) hasn't been run in the Unity editor or on a device yet.\n\nEvery SDK version and what changed in it: [Changelog](https://straitlink.in/changelog/). Not using an SDK? Everything they do is in the [REST API](https://straitlink.in/docs/api/)."
746
+ "markdown": "The iOS (Swift) SDK is at version 0.9.3, the Unity SDK at 0.8.6, the React Native SDK at 0.9.2, the Flutter SDK at 0.9.3, the Android SDK at 0.8.1, the Node SDK at 0.6.4 and the web SDK at 0.6.3. Each one's core is checked against the same shared test files: the same link events, app-state labels and offline retry. Two limits today:\n\n- **iOS**: Universal Links are tested on a real iPhone; install matching after an App Store install is opt-in and proven on iPhone simulators so far.\n- **Unity**: the C# library is tested, but the Unity glue in the guide (MonoBehaviour, PlayerPrefs, the iOS plugin) hasn't been run in the Unity editor or on a device yet.\n\nEvery SDK version and what changed in it: [Changelog](https://straitlink.in/changelog/). Not using an SDK? Everything they do is in the [REST API](https://straitlink.in/docs/api/)."
718
747
  },
719
748
  {
720
749
  "page": "React Native deep linking",
@@ -728,21 +757,21 @@
728
757
  "group": "Platforms",
729
758
  "section": "1. Install the SDK",
730
759
  "url": "https://straitlink.in/docs/sdks/react-native/#1-install-the-sdk",
731
- "markdown": "The SDK installs from its public GitHub repo, pinned to a version tag, together with the two packages it needs:\n\n```bash\nnpm install @straitlink/react-native@0.9.1 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n Either way it installs as `@straitlink/react-native`, so your imports are the same. `AsyncStorage` keeps the once-per-install flag and any open reports that couldn't be sent yet. The Play Install Referrer module gives Android installs an exact match.\n\n**You should see:** `@straitlink/react-native` in your `package.json` dependencies."
760
+ "markdown": "The SDK installs from npm, pinned to a version, together with the two packages it needs:\n\n```bash\nnpm install @straitlink/react-native@0.9.2 @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n`AsyncStorage` keeps the once-per-install flag and any open reports that couldn't be sent yet. The Play Install Referrer module gives Android installs an exact match.\n\n**You should see:** `@straitlink/react-native` in your `package.json` dependencies."
732
761
  },
733
762
  {
734
763
  "page": "React Native deep linking",
735
764
  "group": "Platforms",
736
765
  "section": "2. Create the client once",
737
766
  "url": "https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once",
738
- "markdown": "Create it outside your components and start it when the app launches:\n\n```ts title=\"links.ts (@straitlink/react-native 0.9.1)\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started (safe in apps; never the secret key)\n endpoint: 'https://hilltop.strait.link', // your Strait link host\n storage: AsyncStorage, // required for reliable analytics\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.start();\n```\n\nImport `./links` once from `index.js`. `endpoint` is your workspace's link domain: the SDK sends its requests there and treats links on that host as yours. Call `start()` once. `onLink` replays past events to late subscribers, so your navigator can subscribe after `start()` without missing the launch link.\n\n**You should see:** the app starts as before after a rebuild (`npx react-native run-android`), because the SDK adds a native module."
767
+ "markdown": "Create it outside your components and start it when the app launches:\n\n```ts title=\"links.ts (@straitlink/react-native 0.9.2)\"\nimport AsyncStorage from '@react-native-async-storage/async-storage';\nimport { PlayInstallReferrer } from 'react-native-play-install-referrer';\nimport { createStrait, fromPlayInstallReferrer } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…', // Dashboard → Get started (safe in apps; never the secret key)\n endpoint: 'https://hilltop.strait.link', // your Strait link host\n storage: AsyncStorage, // required for reliable analytics\n installReferrer: fromPlayInstallReferrer(PlayInstallReferrer),\n});\n\nlinks.start();\n```\n\nImport `./links` once from `index.js`. `endpoint` is your workspace's link domain: the SDK sends its requests there and treats links on that host as yours. Call `start()` once. `onLink` replays past events to late subscribers, so your navigator can subscribe after `start()` without missing the launch link.\n\n**You should see:** the app starts as before after a rebuild (`npx react-native run-android`), because the SDK adds a native module."
739
768
  },
740
769
  {
741
770
  "page": "React Native deep linking",
742
771
  "group": "Platforms",
743
772
  "section": "3. Let the phone hand links to your app",
744
773
  "url": "https://straitlink.in/docs/sdks/react-native/#3-let-the-phone-hand-links-to-your-app",
745
- "markdown": "**Android**: one intent filter for verified links on your domain, one for your custom scheme (used when a browser hands a link to your app):\n\n```xml title=\"android/app/src/main/AndroidManifest.xml\"\n<activity android:name=\".MainActivity\" android:launchMode=\"singleTask\" android:exported=\"true\">\n \n <intent-filter android:autoVerify=\"true\">\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"https\" android:host=\"hilltop.strait.link\" />\n </intent-filter>\n \n <intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"yourapp\" />\n </intent-filter>\n</activity>\n```\n\n**iPhone (beta)**: in Xcode, add **Associated Domains** with `applinks:hilltop.strait.link`, and your scheme under **Info → URL Types**.\n\nThe verified-link half also needs your SHA-256 fingerprints and Team ID in Apps & stores; that's step 1 of the [go-live checklist](https://straitlink.in/docs/production-checklist/).\n\n**You should see:** after a rebuild, this command opens your app:\n\n```bash\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"\n```"
774
+ "markdown": "**Android**: one intent filter for verified links on your domain, one for your custom scheme (used when a browser hands a link to your app):\n\n```xml title=\"android/app/src/main/AndroidManifest.xml\"\n<activity android:name=\".MainActivity\" android:launchMode=\"singleTask\" android:exported=\"true\">\n \n <intent-filter android:autoVerify=\"true\">\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"https\" android:host=\"hilltop.strait.link\" />\n </intent-filter>\n \n <intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"yourapp\" />\n </intent-filter>\n</activity>\n```\n\n**iPhone**: in Xcode, add **Associated Domains** with `applinks:hilltop.strait.link`, and your scheme under **Info → URL Types**.\n\nThe verified-link half also needs your SHA-256 fingerprints and Team ID in Apps & stores; that's step 1 of the [go-live checklist](https://straitlink.in/docs/production-checklist/).\n\n**You should see:** after a rebuild, this command opens your app:\n\n```bash\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"\n```"
746
775
  },
747
776
  {
748
777
  "page": "React Native deep linking",
@@ -756,7 +785,7 @@
756
785
  "group": "Platforms",
757
786
  "section": "Or let `strait init` write the setup",
758
787
  "url": "https://straitlink.in/docs/sdks/react-native/#or-let-strait-init-write-the-setup",
759
- "markdown": "From your app's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt writes `links.ts` (step 2) with your key, host and an `onLink` log, and prints the install command, the intent filters for step 3, the iPhone (beta) Associated Domains entry and the Settings fields, each filled in. Steps your project already has are marked `[already done]`. It never edits your existing files."
788
+ "markdown": "From your app's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt writes `links.ts` (step 2) with your key, host and an `onLink` log, and prints the install command, the intent filters for step 3, the iPhone Associated Domains entry and the Settings fields, each filled in. Steps your project already has are marked `[already done]`. It never edits your existing files."
760
789
  },
761
790
  {
762
791
  "page": "React Native deep linking",
@@ -980,7 +1009,7 @@
980
1009
  "group": "Platforms",
981
1010
  "section": "How installs are matched",
982
1011
  "url": "https://straitlink.in/docs/sdks/react-native/reference/#how-installs-are-matched",
983
- "markdown": "| Platform | Method | Precision |\n|---|---|---|\n| Android | Play Install Referrer | Exact |\n| iPhone (beta) | Short-lived device match | Probabilistic |\n| iPhone, paste handoff on (beta) | One-time link from the tap page | Exact |\n\nSee [Deferred deep linking](https://straitlink.in/docs/deferred-deep-linking/) and [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/) for exactly what's collected and how long it's kept."
1012
+ "markdown": "| Platform | Method | Precision |\n|---|---|---|\n| Android | Play Install Referrer | Exact |\n| iPhone, device matching on (opt-in) | Short-lived device match | Probabilistic |\n| iPhone, paste handoff on (opt-in) | One-time link from the tap page | Exact |\n\nSee [Deferred deep linking](https://straitlink.in/docs/deferred-deep-linking/) and [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/) for exactly what's collected and how long it's kept."
984
1013
  },
985
1014
  {
986
1015
  "page": "React Native SDK reference",
@@ -1001,7 +1030,7 @@
1001
1030
  "group": "Platforms",
1002
1031
  "section": "1. Install the SDK",
1003
1032
  "url": "https://straitlink.in/docs/sdks/expo/#1-install-the-sdk",
1004
- "markdown": "```bash\nnpm install @straitlink/react-native@0.9.1\nnpx expo install @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n`npx expo install` picks helper versions that match your Expo SDK.\n\n**You should see:** all three packages in `package.json`."
1033
+ "markdown": "```bash\nnpm install @straitlink/react-native@0.9.2\nnpx expo install @react-native-async-storage/async-storage react-native-play-install-referrer\n```\n\n`npx expo install` picks helper versions that match your Expo SDK.\n\n**You should see:** all three packages in `package.json`."
1005
1034
  },
1006
1035
  {
1007
1036
  "page": "Expo deep linking (Expo Router)",
@@ -1064,21 +1093,21 @@
1064
1093
  "group": "Platforms",
1065
1094
  "section": "1. Install the package",
1066
1095
  "url": "https://straitlink.in/docs/sdks/flutter/#1-install-the-package",
1067
- "markdown": "Add it from pub.dev:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n strait_sdk: ^0.9.2\n```\n\nThen the helper packages:\n\n```bash\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\n[`app_links`](https://pub.dev/packages/app_links) delivers links, [`shared_preferences`](https://pub.dev/packages/shared_preferences) stores the once-per-install flag and unsent reports, [`play_install_referrer`](https://pub.dev/packages/play_install_referrer) gives Android installs an exact match, and [`flutter_timezone`](https://pub.dev/packages/flutter_timezone) provides the IANA time zone the iPhone match needs.\n\n**You should see:** `flutter pub get` finishes without errors."
1096
+ "markdown": "Add it from pub.dev:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n strait_sdk: ^0.9.3\n```\n\nThen the helper packages:\n\n```bash\nflutter pub add app_links shared_preferences play_install_referrer flutter_timezone\n```\n\n[`app_links`](https://pub.dev/packages/app_links) delivers links, [`shared_preferences`](https://pub.dev/packages/shared_preferences) stores the once-per-install flag and unsent reports, [`play_install_referrer`](https://pub.dev/packages/play_install_referrer) gives Android installs an exact match, and [`flutter_timezone`](https://pub.dev/packages/flutter_timezone) provides the IANA time zone the iPhone match needs.\n\n**You should see:** `flutter pub get` finishes without errors."
1068
1097
  },
1069
1098
  {
1070
1099
  "page": "Flutter deep linking",
1071
1100
  "group": "Platforms",
1072
1101
  "section": "2. Create the client once",
1073
1102
  "url": "https://straitlink.in/docs/sdks/flutter/#2-create-the-client-once",
1074
- "markdown": "```dart title=\"links.dart\"\nimport 'dart:async';\nimport 'dart:io' show Platform;\nimport 'dart:ui';\n\nimport 'package:app_links/app_links.dart';\nimport 'package:strait_sdk/strait_sdk.dart';\nimport 'package:flutter/widgets.dart';\nimport 'package:flutter_timezone/flutter_timezone.dart';\nimport 'package:play_install_referrer/play_install_referrer.dart';\nimport 'package:shared_preferences/shared_preferences.dart';\n\n/// Persists the once-per-install flag and unsent open reports.\nclass PrefsStore implements KeyValueStore {\n PrefsStore(this.prefs);\n final SharedPreferences prefs;\n @override\n Future<String?> get(String key) async => prefs.getString(key);\n @override\n Future<void> set(String key, String value) => prefs.setString(key, value);\n}\n\n/// Feeds Flutter lifecycle changes to the SDK.\nclass LifecycleFeed with WidgetsBindingObserver {\n final _ctl = StreamController<AppLifecycle>.broadcast(sync: true);\n Stream<AppLifecycle> get stream => _ctl.stream;\n LifecycleFeed() { WidgetsBinding.instance.addObserver(this); }\n @override\n void didChangeAppLifecycleState(AppLifecycleState s) => _ctl.add(switch (s) {\n AppLifecycleState.resumed => AppLifecycle.active,\n AppLifecycleState.inactive => AppLifecycle.inactive,\n _ => AppLifecycle.background,\n });\n}\n\nlate final StraitLinks links;\n\nFuture<void> startLinks() async {\n WidgetsFlutterBinding.ensureInitialized();\n final timezone = await FlutterTimezone.getLocalTimezone(); // IANA, e.g. Asia/Kolkata\n links = StraitLinks(\n publishableKey: 'st_pub_live_…', // Dashboard → Get started\n endpoint: 'https://hilltop.strait.link', // your workspace's link domain\n platform: Platform.isIOS ? 'ios' : Platform.isAndroid ? 'android' : 'other',\n storage: PrefsStore(await SharedPreferences.getInstance()),\n installReferrer: () async =>\n Platform.isAndroid ? (await PlayInstallReferrer.installReferrer).installReferrer : null,\n deviceFields: () {\n final view = PlatformDispatcher.instance.views.first;\n return DeviceFields(\n screenWidth: portraitScreenWidth(view.physicalSize.width / view.devicePixelRatio, view.physicalSize.height / view.devicePixelRatio),\n pixelRatio: view.devicePixelRatio,\n language: PlatformDispatcher.instance.locale.toLanguageTag(),\n timezone: timezone,\n );\n },\n );\n links.onLink.listen((e) => debugPrint('Strait link: ${e.kind.name} ${e.route.value} ${e.matched} ${e.path}'));\n\n final appLinks = AppLinks();\n await links.start(\n initialUrl: (await appLinks.getInitialLink())?.toString(),\n urls: appLinks.uriLinkStream.map((u) => u.toString()),\n lifecycle: LifecycleFeed().stream,\n );\n}\n```\n\nCall `await startLinks();` in `main()` before `runApp`. Time zone: pass the IANA name (`Asia/Kolkata`); `DateTime.timeZoneName` is an abbreviation on some platforms and won't match. With `flutter_timezone` 4.x use `.identifier`. `app_links` 6+ also emits the launch link on `uriLinkStream`; `start()` ignores that first echo.\n\n**You should see:** the app starts as before."
1103
+ "markdown": "```dart title=\"links.dart\"\nimport 'dart:async';\nimport 'dart:io' show Platform;\nimport 'dart:ui';\n\nimport 'package:app_links/app_links.dart';\nimport 'package:strait_sdk/strait_sdk.dart';\nimport 'package:flutter/widgets.dart';\nimport 'package:flutter_timezone/flutter_timezone.dart';\nimport 'package:play_install_referrer/play_install_referrer.dart';\nimport 'package:shared_preferences/shared_preferences.dart';\n\n/// Persists the once-per-install flag and unsent open reports.\nclass PrefsStore implements KeyValueStore {\n PrefsStore(this.prefs);\n final SharedPreferences prefs;\n @override\n Future<String?> get(String key) async => prefs.getString(key);\n @override\n Future<void> set(String key, String value) => prefs.setString(key, value);\n}\n\n/// Feeds Flutter lifecycle changes to the SDK.\nclass LifecycleFeed with WidgetsBindingObserver {\n final _ctl = StreamController<AppLifecycle>.broadcast(sync: true);\n Stream<AppLifecycle> get stream => _ctl.stream;\n LifecycleFeed() { WidgetsBinding.instance.addObserver(this); }\n @override\n void didChangeAppLifecycleState(AppLifecycleState s) => _ctl.add(switch (s) {\n AppLifecycleState.resumed => AppLifecycle.active,\n AppLifecycleState.inactive => AppLifecycle.inactive,\n _ => AppLifecycle.background,\n });\n}\n\nlate final StraitLinks links;\n\nFuture<void> startLinks() async {\n WidgetsFlutterBinding.ensureInitialized();\n final timezone = (await FlutterTimezone.getLocalTimezone()).identifier; // IANA, e.g. Asia/Kolkata\n links = StraitLinks(\n publishableKey: 'st_pub_live_…', // Dashboard → Get started\n endpoint: 'https://hilltop.strait.link', // your workspace's link domain\n platform: Platform.isIOS ? 'ios' : Platform.isAndroid ? 'android' : 'other',\n storage: PrefsStore(await SharedPreferences.getInstance()),\n installReferrer: () async =>\n Platform.isAndroid ? (await PlayInstallReferrer.installReferrer).installReferrer : null,\n deviceFields: () {\n final view = PlatformDispatcher.instance.views.first;\n return DeviceFields(\n screenWidth: portraitScreenWidth(view.physicalSize.width / view.devicePixelRatio, view.physicalSize.height / view.devicePixelRatio),\n pixelRatio: view.devicePixelRatio,\n language: PlatformDispatcher.instance.locale.toLanguageTag(),\n timezone: timezone,\n );\n },\n );\n links.onLink.listen((e) => debugPrint('Strait link: ${e.kind.name} ${e.route.value} ${e.matched} ${e.path}'));\n\n final appLinks = AppLinks();\n await links.start(\n initialUrl: (await appLinks.getInitialLink())?.toString(),\n urls: appLinks.uriLinkStream.map((u) => u.toString()),\n lifecycle: LifecycleFeed().stream,\n );\n}\n```\n\nCall `await startLinks();` in `main()` before `runApp`. Time zone: pass the IANA name (`Asia/Kolkata`); `DateTime.timeZoneName` is an abbreviation on some platforms and won't match. `flutter pub add` installs `flutter_timezone` 4 or later, which returns a `TimezoneInfo`, hence `.identifier`; on 3.x it returns the name itself, so drop `.identifier`. `app_links` 6+ also emits the launch link on `uriLinkStream`; `start()` ignores that first echo.\n\n**You should see:** the app starts as before."
1075
1104
  },
1076
1105
  {
1077
1106
  "page": "Flutter deep linking",
1078
1107
  "group": "Platforms",
1079
1108
  "section": "3. Let the phone hand links to your app",
1080
1109
  "url": "https://straitlink.in/docs/sdks/flutter/#3-let-the-phone-hand-links-to-your-app",
1081
- "markdown": "**Android**: add the verified-link and custom-scheme intent filters to your main activity in `android/app/src/main/AndroidManifest.xml`, exactly as on the [React Native page](https://straitlink.in/docs/sdks/react-native/#3-let-the-phone-hand-links-to-your-app) (same XML). Flutter's own deep linking must stay off, or it handles the URL before the SDK:\n\n```xml title=\"AndroidManifest.xml (inside <activity>)\"\n<meta-data android:name=\"flutter_deeplinking_enabled\" android:value=\"false\" />\n```\n\n**iPhone (beta)**: in Xcode, add **Associated Domains** with `applinks:hilltop.strait.link`, your scheme under **URL Types**, and `FlutterDeepLinkingEnabled` = `NO` in `Info.plist`.\n\n**You should see:** after `flutter run`, this opens the app:\n\n```bash\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"\n```"
1110
+ "markdown": "**Android**: add the verified-link and custom-scheme intent filters to your main activity in `android/app/src/main/AndroidManifest.xml`, exactly as on the [React Native page](https://straitlink.in/docs/sdks/react-native/#3-let-the-phone-hand-links-to-your-app) (same XML). Flutter's own deep linking must stay off, or it handles the URL before the SDK:\n\n```xml title=\"AndroidManifest.xml (inside <activity>)\"\n<meta-data android:name=\"flutter_deeplinking_enabled\" android:value=\"false\" />\n```\n\n**iPhone**: in Xcode, add **Associated Domains** with `applinks:hilltop.strait.link`, your scheme under **URL Types**, and `FlutterDeepLinkingEnabled` = `NO` in `Info.plist`.\n\n**You should see:** after `flutter run`, this opens the app:\n\n```bash\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"\n```"
1082
1111
  },
1083
1112
  {
1084
1113
  "page": "Flutter deep linking",
@@ -1092,7 +1121,7 @@
1092
1121
  "group": "Platforms",
1093
1122
  "section": "Or let `strait init` write the setup",
1094
1123
  "url": "https://straitlink.in/docs/sdks/flutter/#or-let-strait-init-write-the-setup",
1095
- "markdown": "From your app's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt writes `lib/links.dart` (step 2) with your key and host, and prints the `pubspec.yaml` lines, the `main()` change, the manifest lines for step 3 (including `flutter_deeplinking_enabled`) and the iPhone (beta) settings. Steps your project already has are marked `[already done]`. It never edits your existing files."
1124
+ "markdown": "From your app's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt writes `lib/links.dart` (step 2) with your key and host, and prints the `pubspec.yaml` lines, the `main()` change, the manifest lines for step 3 (including `flutter_deeplinking_enabled`) and the iPhone settings. Steps your project already has are marked `[already done]`. It never edits your existing files."
1096
1125
  },
1097
1126
  {
1098
1127
  "page": "Flutter deep linking",
@@ -1323,7 +1352,7 @@
1323
1352
  "group": "Platforms",
1324
1353
  "section": "1. Add the dependencies",
1325
1354
  "url": "https://straitlink.in/docs/sdks/android/#1-add-the-dependencies",
1326
- "markdown": "The SDK is served by JitPack, built from its public GitHub repo:\n\n```kotlin title=\"settings.gradle.kts\"\ndependencyResolutionManagement {\n repositories {\n google()\n mavenCentral()\n maven(\"https://jitpack.io\")\n }\n}\n```\n\n```kotlin title=\"app/build.gradle.kts\"\ndependencies {\n implementation(\"com.github.bazingga08:strait-sdk-android:v0.8.0\") // Strait SDK\n implementation(\"com.android.installreferrer:installreferrer:2.2\")\n implementation(\"androidx.lifecycle:lifecycle-process:2.8.7\")\n}\n```\n\n**You should see:** Gradle sync finishes, and `import dev.strait.sdk.StraitClient` resolves."
1355
+ "markdown": "The SDK is served by JitPack, built from its public GitHub repo:\n\n```kotlin title=\"settings.gradle.kts\"\ndependencyResolutionManagement {\n repositories {\n google()\n mavenCentral()\n maven(\"https://jitpack.io\")\n }\n}\n```\n\n```kotlin title=\"app/build.gradle.kts\"\ndependencies {\n implementation(\"com.github.bazingga08:strait-sdk-android:v0.8.1\") // Strait SDK\n implementation(\"com.android.installreferrer:installreferrer:2.2\")\n implementation(\"androidx.lifecycle:lifecycle-process:2.8.7\")\n}\n```\n\n**You should see:** Gradle sync finishes, and `import dev.strait.sdk.StraitClient` resolves."
1327
1356
  },
1328
1357
  {
1329
1358
  "page": "Android deep linking (Kotlin)",
@@ -1575,14 +1604,14 @@
1575
1604
  "group": "Platforms",
1576
1605
  "section": null,
1577
1606
  "url": "https://straitlink.in/docs/sdks/ios/",
1578
- "markdown": ":::warn Beta\nThe iOS SDK is tested in CI against the same shared test files as every other SDK, but deferred deep linking hasn't yet been proven on a real iPhone. Use it, tell us what you see, and keep a fallback in mind for launch-critical flows.\n:::"
1607
+ "markdown": ":::note Tested on a real iPhone\nUniversal Links open the app on the link's screen (app open, in the background or closed), tested on a real iPhone (iOS 26) on 21 October 2026. The screen after an App Store install is opt-in (**Dashboard → Settings → iPhone installs**) and proven on iPhone simulators so far. [iPhone status](https://straitlink.in/iphone-status/).\n:::"
1579
1608
  },
1580
1609
  {
1581
1610
  "page": "iOS (Swift)",
1582
1611
  "group": "Platforms",
1583
1612
  "section": "Install",
1584
1613
  "url": "https://straitlink.in/docs/sdks/ios/#install",
1585
- "markdown": "With Swift Package Manager, in Xcode: **File → Add Package Dependencies…**, paste `https://github.com/bazingga08/strait-sdk-swift` and pick version 0.9.2 or later. Or in `Package.swift`:\n\n```swift title=\"Package.swift\"\n.package(url: \"https://github.com/bazingga08/strait-sdk-swift\", from: \"0.9.2\")\n// target dependency: .product(name: \"StraitSDK\", package: \"strait-sdk-swift\")\n```\n\nThen in Xcode: **Signing & Capabilities → Associated Domains → `applinks:hilltop.strait.link`**, and add your custom scheme under **Info → URL Types**. Make sure your Apple Team ID and bundle ID are saved in Dashboard → Apps & stores, so your link domain serves a matching `apple-app-site-association` file."
1614
+ "markdown": "With Swift Package Manager, in Xcode: **File → Add Package Dependencies…**, paste `https://github.com/bazingga08/strait-sdk-swift` and pick version 0.9.3 or later. Or in `Package.swift`:\n\n```swift title=\"Package.swift\"\n.package(url: \"https://github.com/bazingga08/strait-sdk-swift\", from: \"0.9.3\")\n// target dependency: .product(name: \"StraitSDK\", package: \"strait-sdk-swift\")\n```\n\nThen in Xcode: **Signing & Capabilities → Associated Domains → `applinks:hilltop.strait.link`**, and add your custom scheme under **Info → URL Types**. Make sure your Apple Team ID and bundle ID are saved in Dashboard → Apps & stores, so your link domain serves a matching `apple-app-site-association` file."
1586
1615
  },
1587
1616
  {
1588
1617
  "page": "iOS (Swift)",
@@ -1631,7 +1660,7 @@
1631
1660
  "group": "Platforms",
1632
1661
  "section": "Install",
1633
1662
  "url": "https://straitlink.in/docs/sdks/unity/#install",
1634
- "markdown": "Requires Unity 2021.2+ with **Api Compatibility Level = .NET Standard 2.1**. In Unity: **Window → Package Manager → + → Add package from git URL…** and paste:\n\n```text\nhttps://github.com/bazingga08/strait-sdk-unity.git?path=src#v0.8.5\n```\n\nThe package lives in the repo's `src/` folder, so keep the `?path=src` part. An OpenUPM listing is coming soon. Without Package Manager, copy the SDK's `src/*.cs` into your project (for example `Assets/Strait/`), or build the project and drop the DLL into `Assets/Plugins/`. Add `com.android.installreferrer:installreferrer:2.2` to your Android Gradle dependencies (custom `mainTemplate.gradle` or EDM4U)."
1663
+ "markdown": "Requires Unity 2021.2+ with **Api Compatibility Level = .NET Standard 2.1**. In Unity: **Window → Package Manager → + → Add package from git URL…** and paste:\n\n```text\nhttps://github.com/bazingga08/strait-sdk-unity.git?path=src#v0.8.6\n```\n\nThe package lives in the repo's `src/` folder, so keep the `?path=src` part. An OpenUPM listing is coming soon. Without Package Manager, copy the SDK's `src/*.cs` into your project (for example `Assets/Strait/`), or build the project and drop the DLL into `Assets/Plugins/`. Add `com.android.installreferrer:installreferrer:2.2` to your Android Gradle dependencies (custom `mainTemplate.gradle` or EDM4U)."
1635
1664
  },
1636
1665
  {
1637
1666
  "page": "Unity",
@@ -1694,7 +1723,7 @@
1694
1723
  "group": "Platforms",
1695
1724
  "section": "Install",
1696
1725
  "url": "https://straitlink.in/docs/sdks/web/#install",
1697
- "markdown": "Install it from its GitHub repo, pinned to a version tag:\n\n```bash\nnpm install @straitlink/web@0.6.2\n```\n\n Either way it installs as `@straitlink/web`, so your imports are the same."
1726
+ "markdown": "Install it from npm, pinned to a version:\n\n```bash\nnpm install @straitlink/web@0.6.3\n```"
1698
1727
  },
1699
1728
  {
1700
1729
  "page": "Web",
@@ -1757,7 +1786,7 @@
1757
1786
  "group": "Platforms",
1758
1787
  "section": null,
1759
1788
  "url": "https://straitlink.in/docs/smart-banner/",
1760
- "markdown": "The banner is part of the web SDK. It's a plain link to your Strait link, so a tap on it is handled like any other tap: the app opens if it's installed, otherwise the store opens and your app gets the destination on its first open (on iPhone, install matching is in beta)."
1789
+ "markdown": "The banner is part of the web SDK. It's a plain link to your Strait link, so a tap on it is handled like any other tap: the app opens if it's installed, otherwise the store opens and your app gets the destination on its first open (on iPhone, when install matching is switched on)."
1761
1790
  },
1762
1791
  {
1763
1792
  "page": "Smart app banner for mobile web",
@@ -1771,7 +1800,7 @@
1771
1800
  "group": "Platforms",
1772
1801
  "section": "2. Install the web SDK",
1773
1802
  "url": "https://straitlink.in/docs/smart-banner/#2-install-the-web-sdk",
1774
- "markdown": "Install it from GitHub. The banner arrived in version 0.6.0; install 0.6.2 or later for the `@straitlink/web` package name.\n\n```bash\nnpm install @straitlink/web@0.6.2\n```\n\n**You should see:** `@straitlink/web` in your `package.json`."
1803
+ "markdown": "Install it from GitHub. The banner arrived in version 0.6.0; install 0.6.3 or later for the `@straitlink/web` package name.\n\n```bash\nnpm install @straitlink/web@0.6.3\n```\n\n**You should see:** `@straitlink/web` in your `package.json`."
1775
1804
  },
1776
1805
  {
1777
1806
  "page": "Smart app banner for mobile web",
@@ -1823,77 +1852,77 @@
1823
1852
  "markdown": "- [Web SDK](https://straitlink.in/docs/sdks/web/): resolve a deferred link on a first visit to your web app.\n- [Analytics: how we count](https://straitlink.in/docs/analytics/): where banner taps show up."
1824
1853
  },
1825
1854
  {
1826
- "page": "Store sheet (beta) — install another app without leaving yours",
1855
+ "page": "Store sheet (new) — install another app without leaving yours",
1827
1856
  "group": "Platforms",
1828
1857
  "section": null,
1829
1858
  "url": "https://straitlink.in/docs/store-sheet/",
1830
- "markdown": "**Beta.** The store sheet is new and not in a released SDK version yet. On iPhone, the deep link after install relies on install matching, which is in beta. Taps from the store sheet are recorded but not billed during the beta."
1859
+ "markdown": "**New.** In the released SDKs (iOS 0.8.1, React Native and Flutter 0.8.2, and later). On iPhone it needs your app's App Store ID, and the deep link after install relies on install matching, which is opt-in. Taps from the store sheet are recorded but not billed while it's new."
1831
1860
  },
1832
1861
  {
1833
- "page": "Store sheet (beta) — install another app without leaving yours",
1862
+ "page": "Store sheet (new) — install another app without leaving yours",
1834
1863
  "group": "Platforms",
1835
1864
  "section": "What it is, and where it works",
1836
1865
  "url": "https://straitlink.in/docs/store-sheet/#what-it-is-and-where-it-works",
1837
1866
  "markdown": "Some apps show the store as a sheet *inside* the app: the user taps **Install**, installs, and never leaves. The app showing the sheet does this with a store API. A link can't make another company's app do it, so a link tapped inside a social app's in-app browser can't open a store sheet there.\n\nThe store sheet works where **your app is the host**:\n\n- recommending a sibling app (a \"lite\", seller, partner or driver app) from your main app,\n- \"invite a friend to app B\" flows inside app A,\n- web content your app shows in its own WebView, when that page calls your app.\n\nWhat Strait adds is the deep link. A bare store sheet drops context: the user installs app B, and app B doesn't know which screen they wanted. With `openStoreSheet`, app B opens on the linked screen after install."
1838
1867
  },
1839
1868
  {
1840
- "page": "Store sheet (beta) — install another app without leaving yours",
1869
+ "page": "Store sheet (new) — install another app without leaving yours",
1841
1870
  "group": "Platforms",
1842
1871
  "section": "How it works",
1843
1872
  "url": "https://straitlink.in/docs/store-sheet/#how-it-works",
1844
- "markdown": "1. Your app calls `openStoreSheet` with one of your short links. Strait records the tap (`sent_to = store_sheet` in [Analytics](https://straitlink.in/docs/analytics/)).\n2. **Android:** the SDK opens Google Play's inline install, a half-sheet over your app. Google calls inline install a test feature; when Play can't show the sheet, it shows the full listing. If inline install isn't available, the SDK opens the Play app, then the Play web page. Every one of these carries `referrer=strait_link=<link>&strait_click=<tap>`, the same Play Install Referrer the redirect uses, so app B's first open matches exactly.\n3. **iPhone (beta):** the SDK saves this device's match details for the tap, then shows `SKStoreProductViewController` (the full product page as a sheet) or `SKOverlay` (a small card). The link's campaign goes to the App Store as the campaign token. App B's first open finds the link through [iPhone install matching](https://straitlink.in/docs/iphone-install-matching/). If your workspace uses the clipboard boost, you can also have the SDK copy the one-time handoff link for an exact match."
1873
+ "markdown": "1. Your app calls `openStoreSheet` with one of your short links. Strait records the tap (`sent_to = store_sheet` in [Analytics](https://straitlink.in/docs/analytics/)).\n2. **Android:** the SDK opens Google Play's inline install, a half-sheet over your app. Google calls inline install a test feature; when Play can't show the sheet, it shows the full listing. If inline install isn't available, the SDK opens the Play app, then the Play web page. Every one of these carries `referrer=strait_link=<link>&strait_click=<tap>`, the same Play Install Referrer the redirect uses, so app B's first open matches exactly.\n3. **iPhone:** the SDK saves this device's match details for the tap, then shows `SKStoreProductViewController` (the full product page as a sheet) or `SKOverlay` (a small card). The link's campaign goes to the App Store as the campaign token. App B's first open finds the link through [iPhone install matching](https://straitlink.in/docs/iphone-install-matching/). If your workspace uses the clipboard boost, you can also have the SDK copy the one-time handoff link for an exact match."
1845
1874
  },
1846
1875
  {
1847
- "page": "Store sheet (beta) — install another app without leaving yours",
1876
+ "page": "Store sheet (new) — install another app without leaving yours",
1848
1877
  "group": "Platforms",
1849
1878
  "section": "1. Check the installed app's details",
1850
1879
  "url": "https://straitlink.in/docs/store-sheet/#1-check-the-installed-apps-details",
1851
1880
  "markdown": "In Dashboard → Apps & stores, the workspace needs the installed app's Android package name and, for iPhone, its App Store ID. The SDK reads them from Strait. You can also pass them in the call.\n\n**You should see:** both fields filled in for the app being installed."
1852
1881
  },
1853
1882
  {
1854
- "page": "Store sheet (beta) — install another app without leaving yours",
1883
+ "page": "Store sheet (new) — install another app without leaving yours",
1855
1884
  "group": "Platforms",
1856
1885
  "section": "2. Android",
1857
1886
  "url": "https://straitlink.in/docs/store-sheet/#2-android",
1858
1887
  "markdown": "```kotlin title=\"InstallButton.kt\"\n// Off the main thread: it calls Strait first.\nval result = strait.openStoreSheet(\n \"https://hilltop.strait.link/promo\",\n StoreLauncher { s ->\n val intent = Intent(s.action, Uri.parse(s.data)).apply {\n s.packageName?.let { setPackage(it) }\n for ((k, v) in s.extras) when (v) {\n is Boolean -> putExtra(k, v)\n is String -> putExtra(k, v)\n }\n }\n try { activity.startActivity(intent); true }\n catch (_: ActivityNotFoundException) { false }\n },\n StoreSheetOptions(callerId = activity.packageName),\n)\n```\n\n`callerId` is your own package name; Play needs it for the inline sheet. Without it the SDK goes straight to the Play app.\n\n**You should see:** `result.method` is `inline_install` (or `market` when inline install isn't available), and Play opens over your app."
1859
1888
  },
1860
1889
  {
1861
- "page": "Store sheet (beta) — install another app without leaving yours",
1890
+ "page": "Store sheet (new) — install another app without leaving yours",
1862
1891
  "group": "Platforms",
1863
- "section": "3. iPhone (beta)",
1864
- "url": "https://straitlink.in/docs/store-sheet/#3-iphone-beta",
1892
+ "section": "3. iPhone",
1893
+ "url": "https://straitlink.in/docs/store-sheet/#3-iphone",
1865
1894
  "markdown": "```swift title=\"InstallButton.swift\"\nstrait.openStoreSheet(\n url: \"https://hilltop.strait.link/promo\",\n options: StoreSheetOptions(style: .productPage), // or .overlay\n presenter: SystemStoreSheetPresenter(from: viewController)\n) { result in\n print(result.method) // \"product_page\", \"overlay\" or \"none\"\n}\n```\n\n`copyHandoffLink: true` copies the clipboard boost's one-time link as well. It's off by default, because it replaces whatever the user had copied.\n\n**You should see:** the App Store product page as a sheet over your app."
1866
1895
  },
1867
1896
  {
1868
- "page": "Store sheet (beta) — install another app without leaving yours",
1897
+ "page": "Store sheet (new) — install another app without leaving yours",
1869
1898
  "group": "Platforms",
1870
1899
  "section": "4. React Native and Flutter",
1871
1900
  "url": "https://straitlink.in/docs/store-sheet/#4-react-native-and-flutter",
1872
1901
  "markdown": "Both SDKs have `openStoreSheet` with the same options. Opening the store inside the app needs a small native module, because JavaScript and Dart can't start an Android Intent or show StoreKit:\n\n- **React Native:** register a native module named `StraitStoreSheet` with `openIntent(intent)` (Android) and `presentProduct(product, style)` (iPhone). Without it, the SDK opens the Play app or the App Store app instead. The deep link is still kept, but the user leaves your app.\n- **Flutter:** pass a `StoreSheetOpener`. The SDK README has a MethodChannel version.\n\n**You should see:** `result.method` is `inline_install`, `product_page` or `overlay` with the native module, and `market` or `app_store` without it."
1873
1902
  },
1874
1903
  {
1875
- "page": "Store sheet (beta) — install another app without leaving yours",
1904
+ "page": "Store sheet (new) — install another app without leaving yours",
1876
1905
  "group": "Platforms",
1877
1906
  "section": "5. Test it",
1878
1907
  "url": "https://straitlink.in/docs/store-sheet/#5-test-it",
1879
1908
  "markdown": "Uninstall app B. In app A, tap your Install button, install app B from the sheet, then open it.\n\n**You should see:** app B opens on the link's screen. In Dashboard → Analytics, the tap shows with `sent_to = store_sheet`, followed by the install."
1880
1909
  },
1881
1910
  {
1882
- "page": "Store sheet (beta) — install another app without leaving yours",
1911
+ "page": "Store sheet (new) — install another app without leaving yours",
1883
1912
  "group": "Platforms",
1884
1913
  "section": "Limits",
1885
1914
  "url": "https://straitlink.in/docs/store-sheet/#limits",
1886
- "markdown": "- It works only inside your own app. It can't open a store sheet inside another company's app.\n- Android inline install is a Google Play test feature. Play decides when the sheet shows.\n- iPhone: the deep link after install depends on install matching (beta) or the clipboard boost. Apple's App Store sheet doesn't pass any data to the installed app.\n- Offline, the SDK can still open the store when you pass the package name or App Store ID, but the deep link isn't kept."
1915
+ "markdown": "- It works only inside your own app. It can't open a store sheet inside another company's app.\n- Android inline install is a Google Play test feature. Play decides when the sheet shows.\n- iPhone: the deep link after install depends on install matching (opt-in) or the clipboard boost. Apple's App Store sheet doesn't pass any data to the installed app.\n- Offline, the SDK can still open the store when you pass the package name or App Store ID, but the deep link isn't kept."
1887
1916
  },
1888
1917
  {
1889
- "page": "Store sheet (beta) — install another app without leaving yours",
1918
+ "page": "Store sheet (new) — install another app without leaving yours",
1890
1919
  "group": "Platforms",
1891
1920
  "section": "Common errors",
1892
1921
  "url": "https://straitlink.in/docs/store-sheet/#common-errors",
1893
1922
  "markdown": "- **`result.reason` is `no_package` or `no_app_store_id`.** The workspace has no package name or App Store ID for the installed app. Add it in Dashboard → Apps & stores, or pass `androidPackage` / `appStoreId`.\n- **Android opens the full Play listing, not the half-sheet.** Inline install is up to Google Play. Check that `callerId` is your own package name.\n- **App B opens on its home screen.** The tap was offline (`result.reason` is `offline`), or on iPhone install matching found no match. Try again online; on iPhone, see [install matching](https://straitlink.in/docs/iphone-install-matching/)."
1894
1923
  },
1895
1924
  {
1896
- "page": "Store sheet (beta) — install another app without leaving yours",
1925
+ "page": "Store sheet (new) — install another app without leaving yours",
1897
1926
  "group": "Platforms",
1898
1927
  "section": "Next steps",
1899
1928
  "url": "https://straitlink.in/docs/store-sheet/#next-steps",
@@ -1911,7 +1940,7 @@
1911
1940
  "group": "Links",
1912
1941
  "section": "What happens on a tap",
1913
1942
  "url": "https://straitlink.in/docs/short-links/#what-happens-on-a-tap",
1914
- "markdown": "| Who taps | What happens |\n|---|---|\n| Android, app installed, verified link | The phone opens your app directly. The SDK resolves the link and reports the open. |\n| Android, from a browser page | Our server hands the link to your app with your custom scheme. If the app isn't installed, the phone goes to Google Play (link kept for after install) or your website, as set in Apps & stores. |\n| iPhone, app installed | iOS opens your app directly via Universal Links. |\n| iPhone, app not installed | Safari shows a page with a **Get the app** button that opens the App Store. The tap saves a short-lived match so the first open lands on the right screen (beta). |\n| In-app browsers (Instagram, TikTok, X…) | These often don't open apps from a link on their own, so we show a page made for them: on Android, an **Open in app** button; everywhere, steps to open the link in the real browser. |\n| Desktop | Redirects to the link's desktop URL, or the destination. |\n| Link-preview robots (WhatsApp, Slack, iMessage…) | Get a preview page with the link's title, description and image. They're never redirected and never counted as taps. |"
1943
+ "markdown": "| Who taps | What happens |\n|---|---|\n| Android, app installed, verified link | The phone opens your app directly. The SDK resolves the link and reports the open. |\n| Android, from a browser page | Our server hands the link to your app with your custom scheme. If the app isn't installed, the phone goes to Google Play (link kept for after install) or your website, as set in Apps & stores. |\n| iPhone, app installed | iOS opens your app directly via Universal Links. |\n| iPhone, app not installed | Safari shows a page with a **Get the app** button that opens the App Store. With install matching switched on (opt-in), the tap saves a short-lived match so the first open lands on the right screen. |\n| In-app browsers (Instagram, TikTok, X…) | These often don't open apps from a link on their own, so we show a page made for them: on Android, an **Open in app** button; everywhere, steps to open the link in the real browser. |\n| Desktop | Redirects to the link's desktop URL, or the destination. |\n| Link-preview robots (WhatsApp, Slack, iMessage…) | Get a preview page with the link's title, description and image. They're never redirected and never counted as taps. |"
1915
1944
  },
1916
1945
  {
1917
1946
  "page": "Short links",
@@ -1944,9 +1973,9 @@
1944
1973
  {
1945
1974
  "page": "Short links",
1946
1975
  "group": "Links",
1947
- "section": "Deactivating",
1948
- "url": "https://straitlink.in/docs/short-links/#deactivating",
1949
- "markdown": "Deactivating a link (dashboard, or `DELETE /v1/links/{slug}`) stops it resolving. Visitors see a not-found page that links to your website if one is set in Settings."
1976
+ "section": "Pausing a link",
1977
+ "url": "https://straitlink.in/docs/short-links/#pausing-a-link",
1978
+ "markdown": "Pausing a link stops it resolving. In the dashboard, open the link's **More actions** menu in **Links** and choose **Pause…**; from a server, call `DELETE /v1/links/{slug}`. Visitors then see a not-found page that links to your website if one is set in Settings.\n\nA paused link is not deleted. **Links → Paused** lists your paused links, and **Turn back on** makes one work again; taps reach it again within about a minute. From a server, list paused links with `GET /v1/links?status=paused` and turn one back on with `PATCH /v1/links/{slug}` and `{\"isActive\": true}`. If another active link now uses the slug, pause or rename that one first. A link disabled for breaking the acceptable use policy (below) can't be turned back on."
1950
1979
  },
1951
1980
  {
1952
1981
  "page": "Short links",
@@ -2198,7 +2227,7 @@
2198
2227
  "group": "Concepts",
2199
2228
  "section": "What your app sees",
2200
2229
  "url": "https://straitlink.in/docs/deferred-deep-linking/#what-your-app-sees",
2201
- "markdown": "No extra code. The same callback that handles a link opening an installed app also gets the deferred one, once, on the first open after the install. The path and every query parameter on the link survive the install, so a `?ref=CODE` reaches your app too.\n\n```ts\nlinks.onLink((e) => {\n // e.kind: 'direct' (the app was installed) or 'deferred' (tapped before install)\n // e.route: 'install_referrer' on Android; 'fingerprint' or 'clipboard' on iPhone (beta)\n if (e.matched && e.path) openScreen(e.path, e.params); // e.g. '/product/42', { ref: 'CODE' }\n});\n```\n\nTo try it on Android you need three things: the SDK started at launch (it reads the Play Install Referrer for you), a build installed **from Google Play** (an internal testing track works), and, while the app isn't public, **Dashboard → Apps & stores → People without the app → Always Google Play**. Step-by-step: [go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play) and the [React Native test page](https://straitlink.in/docs/sdks/react-native/test/)."
2230
+ "markdown": "No extra code. The same callback that handles a link opening an installed app also gets the deferred one, once, on the first open after the install. The path and every query parameter on the link survive the install, so a `?ref=CODE` reaches your app too.\n\n```ts\nlinks.onLink((e) => {\n // e.kind: 'direct' (the app was installed) or 'deferred' (tapped before install)\n // e.route: 'install_referrer' on Android; 'fingerprint' or 'clipboard' on iPhone (opt-in)\n if (e.matched && e.path) openScreen(e.path, e.params); // e.g. '/product/42', { ref: 'CODE' }\n});\n```\n\nTo try it on Android you need three things: the SDK started at launch (it reads the Play Install Referrer for you), a build installed **from Google Play** (an internal testing track works), and, while the app isn't public, **Dashboard → Apps & stores → People without the app → Always Google Play**. Step-by-step: [go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play) and the [React Native test page](https://straitlink.in/docs/sdks/react-native/test/)."
2202
2231
  },
2203
2232
  {
2204
2233
  "page": "Deferred deep linking",
@@ -2210,9 +2239,9 @@
2210
2239
  {
2211
2240
  "page": "Deferred deep linking",
2212
2241
  "group": "Concepts",
2213
- "section": "iPhone: a short-lived device match (beta)",
2214
- "url": "https://straitlink.in/docs/deferred-deep-linking/#iphone-a-short-lived-device-match-beta",
2215
- "markdown": "iOS doesn't pass anything through the App Store. You choose the method in **Dashboard → Settings → iPhone installs**: device matching (below), paste handoff (an exact one-time link the person allows to paste; reading it shows an \"Allow Paste\" prompt), both, or neither. Device matching starts off for new workspaces. Your app reads the choice from our server on its first open, so changing it needs no app release. Device matching works like this:\n\n1. Someone without your app taps the link in Safari and sees a page with a **Get the app** button.\n2. Tapping it records a coarse device signature together with the link, then opens the App Store.\n3. On first launch, the SDK sends the same coarse fields to `/v1/match`. If a signature from the last **1 hour** matches, your app gets the link.\n\nThe signature is made from:\n\n| Input | From |\n|---|---|\n| Screen width and pixel ratio | The browser, then the app |\n| Language and time zone | The browser, then the app |\n| iOS major version | The browser's user agent, then the app's |\n| Network: prefix, operator (ASN) and a keyed hash of the full IP | Seen by our server; never sent by the app |\n\nEach saved signature can be matched **once** and expires after one hour. It's never used to identify anyone across apps or sites.\n\nWhat we use, how long we keep it, why there's no ATT prompt, the four combinations of the two switches, paste handoff (an exact match the person allows) and a ready-made App Store privacy label are all in [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/).\n\n:::warn Probabilistic, and in beta\nTwo people on the same network with the same phone model, language and time zone who tap different links within the hour are **not matched at all**: when taps look alike, we refuse rather than guess, so neither install is sent to the wrong screen. Each match in Analytics shows its method, so you can see how installs were matched. iPhone matching hasn't been proven on a real device yet.\n:::\n\nEither way, a matched install comes back to the app with its tap id, so purchases made afterwards are credited to that tap (see [Purchases](https://straitlink.in/docs/analytics/#purchases))."
2242
+ "section": "iPhone: a short-lived device match (opt-in)",
2243
+ "url": "https://straitlink.in/docs/deferred-deep-linking/#iphone-a-short-lived-device-match-opt-in",
2244
+ "markdown": "iOS doesn't pass anything through the App Store. You choose the method in **Dashboard → Settings → iPhone installs**: device matching (below), paste handoff (an exact one-time link the person allows to paste; reading it shows an \"Allow Paste\" prompt), both, or neither. Device matching starts off for new workspaces. Your app reads the choice from our server on its first open, so changing it needs no app release. Device matching works like this:\n\n1. Someone without your app taps the link in Safari and sees a page with a **Get the app** button.\n2. Tapping it records a coarse device signature together with the link, then opens the App Store.\n3. On first launch, the SDK sends the same coarse fields to `/v1/match`. If a signature from the last **1 hour** matches, your app gets the link.\n\nThe signature is made from:\n\n| Input | From |\n|---|---|\n| Screen width and pixel ratio | The browser, then the app |\n| Language and time zone | The browser, then the app |\n| iOS major version | The browser's user agent, then the app's |\n| Network: prefix, operator (ASN) and a keyed hash of the full IP | Seen by our server; never sent by the app |\n\nEach saved signature can be matched **once** and expires after one hour. It's never used to identify anyone across apps or sites.\n\nWhat we use, how long we keep it, why there's no ATT prompt, the four combinations of the two switches, paste handoff (an exact match the person allows) and a ready-made App Store privacy label are all in [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/).\n\n:::warn Probabilistic, and opt-in\nTwo people on the same network with the same phone model, language and time zone who tap different links within the hour are **not matched at all**: when taps look alike, we refuse rather than guess, so neither install is sent to the wrong screen. Each match in Analytics shows its method, so you can see how installs were matched. iPhone matching is proven on iPhone and iPad simulators; the real-iPhone run waits on our own App Store listing.\n:::\n\nEither way, a matched install comes back to the app with its tap id, so purchases made afterwards are credited to that tap (see [Purchases](https://straitlink.in/docs/analytics/#purchases))."
2216
2245
  },
2217
2246
  {
2218
2247
  "page": "Deferred deep linking",
@@ -2245,8 +2274,8 @@
2245
2274
  {
2246
2275
  "page": "App Links & Universal Links",
2247
2276
  "group": "Concepts",
2248
- "section": "iPhone (beta)",
2249
- "url": "https://straitlink.in/docs/app-links/#iphone-beta",
2277
+ "section": "iPhone",
2278
+ "url": "https://straitlink.in/docs/app-links/#iphone",
2250
2279
  "markdown": "1. Add the **Associated Domains** capability with `applinks:hilltop.strait.link`.\n2. Save your Apple Team ID and bundle ID in Apps & stores.\n3. Install the app. iOS fetches the file through Apple's CDN, which can lag behind changes."
2251
2280
  },
2252
2281
  {
@@ -2359,7 +2388,7 @@
2359
2388
  "group": "Concepts",
2360
2389
  "section": "Purchases",
2361
2390
  "url": "https://straitlink.in/docs/analytics/#purchases",
2362
- "markdown": "Send events from your app (`trackEvent`) or server (`POST /v1/event`) with an optional value, currency and link id. They appear as the last step of the funnel, with revenue.\n\nEvents can also carry `clickId`, the tap they came from, so revenue lands on that tap's channel and A/B variant. The app SDKs fill it in for you from the most recent link open within 7 days. The tap id reaches the app four ways: in a browser hand-off, in the Google Play install referrer, and in our server's answer when it matches an open from a verified short link or an iPhone install. Each newer open replaces the remembered tap with its own. An explicit `clickId` overrides it. A `clickId` that isn't a tap of your workspace is dropped, and the event is still recorded."
2391
+ "markdown": "Send events from your app (`trackEvent`) or server (`POST /v1/event`) with an optional value, currency and link id. They appear as the last step of the funnel, with revenue. Add `data` (up to 20 key-values, 2 KB) to pass your own fields, such as a plan or order id, to the `app.event` webhook. `data` isn't stored with the event; it is kept 30 days with the webhook delivery. Don't send personal data your receiver doesn't need.\n\nEvents can also carry `clickId`, the tap they came from, so revenue lands on that tap's channel and A/B variant. The app SDKs fill it in for you from the most recent link open within 7 days. The tap id reaches the app four ways: in a browser hand-off, in the Google Play install referrer, and in our server's answer when it matches an open from a verified short link or an iPhone install. Each newer open replaces the remembered tap with its own. An explicit `clickId` overrides it. A `clickId` that isn't a tap of your workspace is dropped, and the event is still recorded."
2363
2392
  },
2364
2393
  {
2365
2394
  "page": "Analytics: how we count",
@@ -2401,7 +2430,7 @@
2401
2430
  "group": "Server & API",
2402
2431
  "section": "Install",
2403
2432
  "url": "https://straitlink.in/docs/sdks/node/#install",
2404
- "markdown": "Install it from its GitHub repo, pinned to a version tag:\n\n```bash\nnpm install @straitlink/node@0.6.4\n```\n\n Either way it installs as `@straitlink/node`, so your imports are the same."
2433
+ "markdown": "Install it from npm, pinned to a version:\n\n```bash\nnpm install @straitlink/node@0.6.4\n```"
2405
2434
  },
2406
2435
  {
2407
2436
  "page": "Node (server)",
@@ -2478,7 +2507,7 @@
2478
2507
  "group": "Server & API",
2479
2508
  "section": "When changes take effect",
2480
2509
  "url": "https://straitlink.in/docs/api/#when-changes-take-effect",
2481
- "markdown": "Link edits (`PATCH /v1/links/{slug}`) and deactivations (`DELETE /v1/links/{slug}`) apply to taps within 30 seconds. They apply right away on our server and at the Cloudflare location that carried your request; plain redirects decided at other Cloudflare locations can follow the old version for up to 30 seconds. See [How a tap is handled](https://straitlink.in/docs/short-links/#how-a-tap-is-handled)."
2510
+ "markdown": "Link edits (`PATCH /v1/links/{slug}`) and deactivations (`DELETE /v1/links/{slug}`) and turning a link back on (`PATCH` with `isActive`) apply to taps within 30 seconds. They apply right away on our server and at the Cloudflare location that carried your request; plain redirects decided at other Cloudflare locations can follow the old version for up to 30 seconds. See [How a tap is handled](https://straitlink.in/docs/short-links/#how-a-tap-is-handled)."
2482
2511
  },
2483
2512
  {
2484
2513
  "page": "REST API",
@@ -2541,14 +2570,14 @@
2541
2570
  "group": "Server & API",
2542
2571
  "section": "Events",
2543
2572
  "url": "https://straitlink.in/docs/webhooks/#events",
2544
- "markdown": "| Event | Sent when | `data` fields |\n|---|---|---|\n| `link.clicked` | Someone taps a link (browser taps, and verified-link opens your app reports) | `linkId`, `slug`, `platform`, `clickId`, `sentTo`, `deviceClass`, `variant`, `source` |\n| `install.matched` | A first launch after install is matched to a link | `linkId`, `clickId`, `matchMethod`, `platform` |\n| `app.opened` | A link opens your app | `linkId`, `clickId`, `kind`, `route`, `appState`, `isInstall`, `matched`, `platform` |\n| `ping` | You send a test event | `webhookId`, `message` |\n| `referral.converted` | Preview, not switched on yet: a referred install opens and matches its tap ([Referrals](https://straitlink.in/docs/referrals/)) | `referralCode`, `linkId`, `clickId`, `platform`, `matchMethod` |\n\nFields that don't apply to an event are left out. Taps on an expired link are recorded in Analytics but don't send `link.clicked`."
2573
+ "markdown": "| Event | Sent when | `data` fields |\n|---|---|---|\n| `link.clicked` | Someone taps a link (browser taps, and verified-link opens your app reports) | `linkId`, `slug`, `platform`, `clickId`, `sentTo`, `deviceClass`, `variant`, `source` |\n| `install.matched` | A first launch after install is matched to a link | `linkId`, `clickId`, `matchMethod`, `platform` |\n| `app.opened` | A link opens your app | `linkId`, `clickId`, `kind`, `route`, `appState`, `isInstall`, `matched`, `platform` |\n| `app.event` | Your app or server sends an event (`trackEvent` / `POST /v1/event`): signup, purchase, anything you name. Not ticked on existing webhooks: tick it to opt in | `event`, `value`, `currency`, `eventId`, `clickId`, `linkId`, `slug`, `platform`, `occurredAt`, `sentWith`, `data` |\n| `ping` | You send a test event | `webhookId`, `message` |\n| `referral.converted` | Preview, not switched on yet: a referred install opens and matches its tap ([Referrals](https://straitlink.in/docs/referrals/)) | `referralCode`, `linkId`, `clickId`, `platform`, `matchMethod` |\n\nFields that don't apply to an event are left out. Taps on an expired link are recorded in Analytics but don't send `link.clicked`.\n\nFor `app.event`, every field is always present (`null` when the event didn't carry it). `clickId`, `linkId` and `slug` name the tap the event came from. `data` is your own key-values from the request. A retry with the same `eventId` is sent once.\n\n`sentWith` says which key sent the event: `publishable_key` (your app; anyone with your app can send these) or `secret_key` (your server). Trust revenue only from `secret_key`, or check it against your own records.\n\nEach workspace's `app.event` webhooks for events sent with the publishable key are limited to 600 at once, then 10 a second. Past that, events are still recorded and counted, but not sent to your webhook. Events your server sends with the secret key are not limited."
2545
2574
  },
2546
2575
  {
2547
2576
  "page": "Webhooks",
2548
2577
  "group": "Server & API",
2549
2578
  "section": "Payload",
2550
2579
  "url": "https://straitlink.in/docs/webhooks/#payload",
2551
- "markdown": "```http title=\"Request we send\" paper\nPOST /your/webhook HTTP/1.1\nContent-Type: application/json\nX-Strait-Event: install.matched\nX-Strait-Delivery: 7f3e2c1a-9b4d-4e8f-a6c5-2d1b0e9f8a73\nX-Strait-Attempt: 1\nX-Strait-Signature: t=1767225600,v1=6a1f0c3e9b…\n\n{\n \"id\": \"7f3e2c1a-9b4d-4e8f-a6c5-2d1b0e9f8a73\",\n \"event\": \"install.matched\",\n \"createdAt\": \"2026-01-01T00:00:00.000Z\",\n \"data\": {\n \"linkId\": \"lnk_123\",\n \"clickId\": \"c_123\",\n \"matchMethod\": \"install_referrer\",\n \"platform\": \"android\"\n }\n}\n```"
2580
+ "markdown": "```http title=\"Request we send\" paper\nPOST /your/webhook HTTP/1.1\nContent-Type: application/json\nX-Strait-Event: install.matched\nX-Strait-Delivery: 7f3e2c1a-9b4d-4e8f-a6c5-2d1b0e9f8a73\nX-Strait-Attempt: 1\nX-Strait-Signature: t=1767225600,v1=6a1f0c3e9b…\n\n{\n \"id\": \"7f3e2c1a-9b4d-4e8f-a6c5-2d1b0e9f8a73\",\n \"event\": \"install.matched\",\n \"createdAt\": \"2026-01-01T00:00:00.000Z\",\n \"data\": {\n \"linkId\": \"lnk_123\",\n \"clickId\": \"c_123\",\n \"matchMethod\": \"install_referrer\",\n \"platform\": \"android\"\n }\n}\n```\n\n```json title=\"app.event data\" paper\n{\n \"event\": \"purchase\",\n \"value\": 499,\n \"currency\": \"INR\",\n \"eventId\": \"ord_1001\",\n \"clickId\": \"3f2b8c1e-9a7d-4e21-b6c5-0d1e2f3a4b5c\",\n \"linkId\": \"lnk_123\",\n \"slug\": \"summer\",\n \"platform\": \"android\",\n \"occurredAt\": \"2026-01-01T00:00:00.000Z\",\n \"sentWith\": \"publishable_key\",\n \"data\": { \"plan\": \"gold\" }\n}\n```"
2552
2581
  },
2553
2582
  {
2554
2583
  "page": "Webhooks",
@@ -2583,7 +2612,7 @@
2583
2612
  "group": "Server & API",
2584
2613
  "section": "What it does",
2585
2614
  "url": "https://straitlink.in/docs/referrals/#what-it-does",
2586
- "markdown": "1. **A code on the tap.** Each tap can carry a referral code: either the link's own code, or a code added to the link's address for that one tap.\n2. **The code survives the install.** When the person installs your app and opens it, the deferred answer your app gets on first open includes `referralCode`. On Android this comes through the Google Play install, so it's exact. On iPhone it comes through install matching, which is in [beta](https://straitlink.in/docs/iphone-install-matching/).\n3. **Your server hears about it.** A `referral.converted` webhook fires once for each referred install that opens and matches. It carries the code, the link and the tap it came from.\n4. **You see it per code.** The dashboard's Referrals page (preview) shows, for each code, the taps that carried it and the referred installs.\n\nStrait doesn't grant rewards, hold credits or pay anyone out. That stays with your server, which decides from the webhook what each person gets."
2615
+ "markdown": "1. **A code on the tap.** Each tap can carry a referral code: either the link's own code, or a code added to the link's address for that one tap.\n2. **The code survives the install.** When the person installs your app and opens it, the deferred answer your app gets on first open includes `referralCode`. On Android this comes through the Google Play install, so it's exact. On iPhone it comes through install matching, which is [opt-in](https://straitlink.in/docs/iphone-install-matching/).\n3. **Your server hears about it.** A `referral.converted` webhook fires once for each referred install that opens and matches. It carries the code, the link and the tap it came from.\n4. **You see it per code.** The dashboard's Referrals page (preview) shows, for each code, the taps that carried it and the referred installs.\n\nStrait doesn't grant rewards, hold credits or pay anyone out. That stays with your server, which decides from the webhook what each person gets."
2587
2616
  },
2588
2617
  {
2589
2618
  "page": "Referrals (preview)",
@@ -2653,7 +2682,7 @@
2653
2682
  "group": "Server & API",
2654
2683
  "section": "Every error, by status",
2655
2684
  "url": "https://straitlink.in/docs/errors/#every-error-by-status",
2656
- "markdown": "The list below is generated from the server's source, so it's exactly what the API can send. `…` stands for a value filled in at the time (a number, a name or a list). Field checks that build their message from what you sent (a slug, a hostname, a date) aren't listed one by one; they're all `400`. Errors that only the dashboard can get (billing, the Firebase import) are left out.\n\n### 400 Bad request\n\n- `country must be a 2-letter country code (ISO 3166-1, e.g. IN)`\n- `destination_blocked`\n- `deviceId required: the SDK debug device id (8–64 letters, digits, - or _)`\n- `expiresAt must be epoch milliseconds, an ISO 8601 date-time, or null`\n- `from / to must be ISO dates (e.g. 2026-01-31) or epoch ms`\n- `invalid cursor`\n- `invalid_domain`\n- `invalid_report`\n- `limit must be 1 to 100`\n- `limit must be an integer from 1 to 100`\n- `linkId + device fields required`\n- `linkId required`\n- `links must be an array of 1 to … links`\n- `longUrl must be https`\n- `malformed JSON body`\n- `part must be one of …`\n- `platform + device fields required`\n- `platform must be 'android' or 'ios'`\n- `refused`\n- `slug ([a-zA-Z0-9_-], ≤64) and https longUrl are required`\n- `tenantId is required with the admin key`\n- `tenantId required`\n- `token must be a Strait handoff token (22 base64url characters)`\n- `token was issued for another part`\n- `type the workspace link handle exactly to confirm`\n- `ua required: a preset (…) or a raw user agent`\n- `unknown linkId`\n- `url must be a full short link, e.g. https://…/your-slug`\n- `url must be a full short link, e.g. https://you.strait.link/summer`\n\n### 401 Unauthorized\n\n- `analytics needs a secret key (Authorization: Bearer st_live_…); publishable keys only work for the SDK endpoints`\n- `download link expired or already used — start the download again`\n- `invalid API key`\n- `invalid publishable key`\n- `invalid secret key`\n- `publishable key required (find it in Dashboard → Get started)`\n- `secret key (Authorization: Bearer st_live_…) or publishableKey required`\n- `secret key or dashboard session required (Authorization: Bearer …)`\n- `secret key required (Authorization: Bearer st_live_…)`\n- `session expired — sign in again`\n- `sign in to the dashboard to close a workspace (a key cannot)`\n- `the referral report needs a secret key (Authorization: Bearer st_live_…)`\n- `unauthorized`\n\n### 402 Payment required\n\n- `plan_required`\n- *(plan limit, `reason: \"clicks\"`, message from the database)*\n- *(plan limit, `reason: \"links\"`, message from the database)*\n- *(plan limit, `reason: \"webhooks\"`, message from the database)*\n\n### 403 Forbidden\n\n- `fresh_mfa_required`\n- `mfa_required`\n- `not a member of this workspace`\n- `only an owner can close this workspace`\n- `only owners and admins can export (an export contains all workspace data)`\n- `viewers cannot add domains`\n- `viewers cannot create links`\n- `viewers cannot redeliver webhooks`\n- `viewers cannot remove domains`\n- `viewers cannot see webhook deliveries`\n\n### 404 Not found\n\n- `delivery not found`\n- `domain not found`\n- `link not found`\n- `no active link \"…\" in this workspace`\n- `no such link in this workspace`\n- `not a trace token`\n- `not configured`\n- `not found`\n- `webhook not found`\n- `workspace not found`\n\n### 409 Conflict\n\n- `A workspace can have up to … custom domains. Remove one first.`\n- `already delivered`\n- `cancel your paid plan in Plan & usage first, then close the workspace`\n- `slug already exists in this workspace`\n- `this delivery is being sent right now — try again in … minutes`\n- `This domain is already added to a Strait workspace.`\n- `this old link (legacyHost + legacyCode) is already imported in this workspace`\n\n### 413 Payload too large\n\n- `request body too large`\n\n### 421 Misdirected request\n\n- `Use https://strait.link (or your link domain); this origin only answers Strait's edge.`\n\n### 422 Unprocessable\n\n- `dns_failed`\n\n### 429 Too many requests\n\n- `new_account_limit`\n- `QR rendering is busy: try again in a few seconds, or ask for format=svg`\n- `rate limited`\n- `rate limited: at most 30 analytics calls a minute per workspace`\n- `rate limited: at most 30 referral report calls a minute per workspace`\n- `rate limited: at most 60 QR codes a minute per workspace`\n- `rate limited: domain status is checked at most every few seconds`\n- `rate_limited`\n- `Too many domain changes. Try again in a few minutes.`\n\n### 500 Internal error\n\n- `internal error`\n\n### 502 Bad gateway\n\n- `Couldn't remove the domain from our certificate provider. It's still listed; try again in a minute.`\n- `Couldn't set up the domain with our certificate provider. Nothing was saved; try again in a minute.`\n\n### 503 Unavailable\n\n- `domains_not_enabled`\n- `domains_read_only`"
2685
+ "markdown": "The list below is generated from the server's source, so it's exactly what the API can send. `…` stands for a value filled in at the time (a number, a name or a list). Field checks that build their message from what you sent (a slug, a hostname, a date) aren't listed one by one; they're all `400`. Errors that only the dashboard can get (billing, the Firebase import) are left out.\n\n### 400 Bad request\n\n- `country must be a 2-letter country code (ISO 3166-1, e.g. IN)`\n- `destination_blocked`\n- `deviceId required: the SDK debug device id (8–64 letters, digits, - or _)`\n- `expiresAt must be epoch milliseconds, an ISO 8601 date-time, or null`\n- `from / to must be ISO dates (e.g. 2026-01-31) or epoch ms`\n- `invalid cursor`\n- `invalid_domain`\n- `invalid_report`\n- `limit must be 1 to 100`\n- `limit must be an integer from 1 to 100`\n- `linkId + device fields required`\n- `linkId required`\n- `links must be an array of 1 to … links`\n- `longUrl must be https`\n- `malformed JSON body`\n- `part must be one of …`\n- `platform + device fields required`\n- `platform must be 'android' or 'ios'`\n- `refused`\n- `slug ([a-zA-Z0-9_-], ≤64) and https longUrl are required`\n- `tenantId is required with the admin key`\n- `tenantId required`\n- `token must be a Strait handoff token (22 base64url characters)`\n- `token was issued for another part`\n- `type the workspace link handle exactly to confirm`\n- `ua required: a preset (…) or a raw user agent`\n- `unknown linkId`\n- `url must be a full short link, e.g. https://…/your-slug`\n- `url must be a full short link, e.g. https://you.strait.link/summer`\n\n### 401 Unauthorized\n\n- `analytics needs a secret key (Authorization: Bearer st_live_…); publishable keys only work for the SDK endpoints`\n- `download link expired or already used — start the download again`\n- `invalid API key`\n- `invalid publishable key`\n- `invalid secret key`\n- `publishable key required (find it in Dashboard → Get started)`\n- `secret key (Authorization: Bearer st_live_…) or publishableKey required`\n- `secret key or dashboard session required (Authorization: Bearer …)`\n- `secret key required (Authorization: Bearer st_live_…)`\n- `session expired — sign in again`\n- `sign in to the dashboard to close a workspace (a key cannot)`\n- `the referral report needs a secret key (Authorization: Bearer st_live_…)`\n- `unauthorized`\n\n### 402 Payment required\n\n- `plan_required`\n- *(plan limit, `reason: \"clicks\"`, message from the database)*\n- *(plan limit, `reason: \"links\"`, message from the database)*\n- *(plan limit, `reason: \"webhooks\"`, message from the database)*\n\n### 403 Forbidden\n\n- `fresh_mfa_required`\n- `mfa_required`\n- `not a member of this workspace`\n- `only an owner can close this workspace`\n- `only owners and admins can export (an export contains all workspace data)`\n- `viewers cannot add domains`\n- `viewers cannot create links`\n- `viewers cannot redeliver webhooks`\n- `viewers cannot remove domains`\n- `viewers cannot see webhook deliveries`\n\n### 404 Not found\n\n- `delivery not found`\n- `domain not found`\n- `link not found`\n- `no active link \"…\" in this workspace`\n- `no such link in this workspace`\n- `not a trace token`\n- `not configured`\n- `not found`\n- `webhook not found`\n- `workspace not found`\n\n### 409 Conflict\n\n- `A workspace can have up to … custom domains. Remove one first.`\n- `already delivered`\n- `cancel your paid plan in Plan & usage first, then close the workspace`\n- `slug already exists in this workspace`\n- `this delivery is being sent right now — try again in … minutes`\n- `This domain is already added to a Strait workspace.`\n- `this old link (legacyHost + legacyCode) is already imported in this workspace`\n- `your payment is still being confirmed — open Plan & usage in a minute, cancel the plan there, then close the workspace`\n\n### 413 Payload too large\n\n- `request body too large`\n\n### 421 Misdirected request\n\n- `Use https://strait.link (or your link domain); this origin only answers Strait's edge.`\n\n### 422 Unprocessable\n\n- `dns_failed`\n\n### 429 Too many requests\n\n- `new_account_limit`\n- `QR rendering is busy: try again in a few seconds, or ask for format=svg`\n- `rate limited`\n- `rate limited: at most 30 analytics calls a minute per workspace`\n- `rate limited: at most 30 referral report calls a minute per workspace`\n- `rate limited: at most 30 usage calls a minute per workspace`\n- `rate limited: at most 60 QR codes a minute per workspace`\n- `rate limited: domain status is checked at most every few seconds`\n- `rate_limited`\n- `Too many domain changes. Try again in a few minutes.`\n\n### 500 Internal error\n\n- `internal error`\n\n### 502 Bad gateway\n\n- `Couldn't remove the domain from our certificate provider. It's still listed; try again in a minute.`\n- `Couldn't set up the domain with our certificate provider. Nothing was saved; try again in a minute.`\n\n### 503 Unavailable\n\n- `domains_not_enabled`\n- `domains_read_only`"
2657
2686
  },
2658
2687
  {
2659
2688
  "page": "Error reference",
@@ -2737,7 +2766,7 @@
2737
2766
  "group": "Server & API",
2738
2767
  "section": "4. Fix the items that failed",
2739
2768
  "url": "https://straitlink.in/docs/recipes/batch-create-links/#4-fix-the-items-that-failed",
2740
- "markdown": "Read `status` and `error` for each item in `failed`:\n\n| `status` | What it means | What to do |\n|---|---|---|\n| `400` | A field is wrong (slug rules, a non-https URL), or `error` is `destination_blocked` | Fix the item. `reason` says why a destination was blocked. |\n| `402` | Your plan's link limit is reached | Upgrade, or deactivate links you no longer use |\n| `409` | The slug already exists (counted as `existing` above) | Nothing, if it's your earlier run. To point it somewhere new, [update the link](https://straitlink.in/docs/api/update-link/). |\n\n**You should see:** an empty `failed` list after you fix those items and run the job again."
2769
+ "markdown": "Read `status` and `error` for each item in `failed`:\n\n| `status` | What it means | What to do |\n|---|---|---|\n| `400` | A field is wrong (slug rules, a non-https URL), or `error` is `destination_blocked` | Fix the item. `reason` says why a destination was blocked. |\n| `402` | Your plan's link limit is reached | Upgrade, or wait for next month: the limit counts links created this month |\n| `409` | The slug already exists (counted as `existing` above) | Nothing, if it's your earlier run. To point it somewhere new, [update the link](https://straitlink.in/docs/api/update-link/). |\n\n**You should see:** an empty `failed` list after you fix those items and run the job again."
2741
2770
  },
2742
2771
  {
2743
2772
  "page": "Create links in bulk from your backend (batch API)",
@@ -2856,7 +2885,7 @@
2856
2885
  "group": "Server & API",
2857
2886
  "section": "5. Read the results",
2858
2887
  "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#5-read-the-results",
2859
- "markdown": "Open **Dashboard → Analytics** and pick `diwali-2026` in the **Campaign** filter. Taps count under the WhatsApp channel because of the `utm_source` on the destination, and taps inside WhatsApp's own browser are labelled WhatsApp anyway. On Android, someone without the app goes through Google Play and lands on the sale screen on the first launch (iPhone install matching is in public beta). To react in your own systems, subscribe to `link.clicked` and `install.matched` [webhooks](https://straitlink.in/docs/recipes/verify-webhook-signatures/).\n\n**You should see:** your test tap under channel **WhatsApp** and campaign `diwali-2026`, and, on a phone with the app, the sale screen opening."
2888
+ "markdown": "Open **Dashboard → Analytics** and pick `diwali-2026` in the **Campaign** filter. Taps count under the WhatsApp channel because of the `utm_source` on the destination, and taps inside WhatsApp's own browser are labelled WhatsApp anyway. On Android, someone without the app goes through Google Play and lands on the sale screen on the first launch (on iPhone, when you switch on install matching). To react in your own systems, subscribe to `link.clicked` and `install.matched` [webhooks](https://straitlink.in/docs/recipes/verify-webhook-signatures/).\n\n**You should see:** your test tap under channel **WhatsApp** and campaign `diwali-2026`, and, on a phone with the app, the sale screen opening."
2860
2889
  },
2861
2890
  {
2862
2891
  "page": "Send campaign links via WhatsApp Business API templates",
@@ -2872,12 +2901,131 @@
2872
2901
  "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#next-steps",
2873
2902
  "markdown": "- [Verify webhook signatures](https://straitlink.in/docs/recipes/verify-webhook-signatures/): act on taps and installs from this campaign in your backend.\n- [Analytics: how we count](https://straitlink.in/docs/analytics/): what counts as a tap, an install and an open."
2874
2903
  },
2904
+ {
2905
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2906
+ "group": "AI tools & CLI",
2907
+ "section": null,
2908
+ "url": "https://straitlink.in/docs/mcp/",
2909
+ "markdown": "The Strait MCP server is a small program, `@straitlink/mcp` on npm (first published 15 October 2026; version 0.2.1 since 23 October), that lets an AI tool use your Strait workspace. MCP (Model Context Protocol) is the standard way AI tools such as Claude and Cursor plug into other services. Your AI tool starts the server on your computer when it needs it, so there is nothing to host.\n\nFour steps. Each one says what to do and what you should see."
2910
+ },
2911
+ {
2912
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2913
+ "group": "AI tools & CLI",
2914
+ "section": "1. Make a secret key for your AI tool",
2915
+ "url": "https://straitlink.in/docs/mcp/#1-make-a-secret-key-for-your-ai-tool",
2916
+ "markdown": "**Do this:** open **Dashboard → Settings → Secret keys** and create a new key. Name it after the tool, for example `claude-code`. Copy it (it starts with `st_live_…`). The dashboard shows it only once.\n\n:::warn The secret key stays on your computer\nA secret key can create and change your links. Put it only in your AI tool's settings on your own computer. Never put it in an app, a website or a git repository. The publishable key (`st_pub_live_…`) doesn't work here: it is for the SDKs in your app.\n:::\n\n**You should see:** the new key in the list, with its name. A key of its own means you can revoke it later without touching your servers."
2917
+ },
2918
+ {
2919
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2920
+ "group": "AI tools & CLI",
2921
+ "section": "2. Add the server to your AI tool",
2922
+ "url": "https://straitlink.in/docs/mcp/#2-add-the-server-to-your-ai-tool",
2923
+ "markdown": "**Do this:** pick your tool and paste its block. Put your key where it says `st_live_…your_key`.\n\n**Claude Code**: in a terminal, run:\n\n```bash\nclaude mcp add strait --env STRAIT_SECRET_KEY=st_live_…your_key -- npx -y @straitlink/mcp\n```\n\n**Claude Desktop**: open **Settings → Developer → Edit config**. Add this inside the file, then quit and reopen Claude Desktop.\n\n```json title=\"claude_desktop_config.json\"\n{\n \"mcpServers\": {\n \"strait\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@straitlink/mcp\"],\n \"env\": { \"STRAIT_SECRET_KEY\": \"st_live_…your_key\" }\n }\n }\n}\n```\n\n**Cursor**: put the same `\"mcpServers\"` block in `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project; don't commit that file with a key in it).\n\n**Codex**: add this to `~/.codex/config.toml`:\n\n```toml title=\"~/.codex/config.toml\"\n[mcp_servers.strait]\ncommand = \"npx\"\nargs = [\"-y\", \"@straitlink/mcp\"]\nenv = { STRAIT_SECRET_KEY = \"st_live_…your_key\" }\n```\n\n**You should see:** a server called `strait` marked connected, with 16 tools under it. In Claude Code, type `/mcp` to see it; in Cursor, open **Settings → MCP**."
2924
+ },
2925
+ {
2926
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2927
+ "group": "AI tools & CLI",
2928
+ "section": "3. Ask it something that reads",
2929
+ "url": "https://straitlink.in/docs/mcp/#3-ask-it-something-that-reads",
2930
+ "markdown": "**Do this:** in a chat, ask: \"List my Strait links.\"\n\n**You should see:** your links with their short URLs, the same ones as **Dashboard → Links**. If you have none yet, the answer is an empty list."
2931
+ },
2932
+ {
2933
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2934
+ "group": "AI tools & CLI",
2935
+ "section": "4. Make a test link",
2936
+ "url": "https://straitlink.in/docs/mcp/#4-make-a-test-link",
2937
+ "markdown": "**Do this:** ask: \"Create a Strait link `mcp-test` to https://shop.example.com/product/42.\"\n\n**You should see:** a short URL like `https://hilltop.strait.link/mcp-test`. It shows up in **Dashboard → Links**, and tapping it on a phone with your app installed opens the app."
2938
+ },
2939
+ {
2940
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2941
+ "group": "AI tools & CLI",
2942
+ "section": "The 16 tools",
2943
+ "url": "https://straitlink.in/docs/mcp/#the-16-tools",
2944
+ "markdown": "Six tools write; ten only read. There is no delete tool. Three of the writing tools change something live, so they first show you a preview and act only after you say yes: `update_link`, `deactivate_link` and `redeliver_webhook_delivery`.\n\n| Tool | What it does | Writes? |\n|---|---|---|\n| `create_link` | Creates one link and returns its `shortUrl` | Yes |\n| `batch_create_links` | Creates up to 500 links in one call; safe to re-run | Yes |\n| `update_link` | Changes destination, title, tags or expiry, or turns a link off or back on with `isActive` (sent on its own), after you confirm | Yes |\n| `deactivate_link` | Pauses a link, after you confirm. Turn it back on later with `update_link` | Yes |\n| `test_webhook` | Sends one test event to your webhook and shows the answer | Yes |\n| `redeliver_webhook_delivery` | Sends a failed webhook delivery again, after you confirm | Yes |\n| `get_link` | Reads one link by its slug | No |\n| `list_links` | Lists your links, a page at a time. `status: \"paused\"` lists links that are turned off | No |\n| `get_analytics` | Taps, app opens, installs, conversions and revenue, by day, channel, link or platform | No |\n| `get_usage` | This month's taps and new links against your plan | No |\n| `get_referrals` | Taps and installs per referral code (referrals are a preview, not switched on yet) | No |\n| `list_webhook_deliveries` | A webhook's recent deliveries and retries, or your list of webhooks | No |\n| `simulate_tap` | Shows what one tap from one device or app would do; nothing is recorded or billed | No |\n| `trace_tap` | Walks a link through WhatsApp, Instagram, Safari, Chrome and more, one row each (no key needed for a demo token) | No |\n| `check_app_links` | Checks a domain's `assetlinks.json` and Apple association file, with a fix for each problem (no key needed) | No |\n| `search_docs` | Searches these docs, offline (no key needed) | No |\n\nEvery other tool needs the secret key. The server sends your key only to `https://strait.link`, and hides anything that looks like a secret key in its answers."
2945
+ },
2946
+ {
2947
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2948
+ "group": "AI tools & CLI",
2949
+ "section": "Things to ask",
2950
+ "url": "https://straitlink.in/docs/mcp/#things-to-ask",
2951
+ "markdown": "- \"Why does `summer` open the browser on Instagram instead of my app?\"\n- \"Which channel brought the most installs last month?\"\n- \"How much of this month's plan have we used?\"\n- \"My webhook stopped firing. Show me its last deliveries and send it a test.\"\n- \"Turn the spring-sale link back on.\" It finds the link among your paused links, shows where it will go, and asks before it acts."
2952
+ },
2953
+ {
2954
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2955
+ "group": "AI tools & CLI",
2956
+ "section": "Common errors",
2957
+ "url": "https://straitlink.in/docs/mcp/#common-errors",
2958
+ "markdown": "- **The tool says it needs a secret key.** `STRAIT_SECRET_KEY` is missing or misspelled in the tool's config. Add it as in step 2 and restart the tool.\n- **\"STRAIT_SECRET_KEY is a publishable key\".** You pasted the `st_pub_live_…` key. Make a secret key in step 1 instead.\n- **The server doesn't start.** Check `node --version` prints 20 or higher. If not, install Node.js from nodejs.org and restart the tool."
2959
+ },
2960
+ {
2961
+ "page": "MCP server: use Strait from Claude, Cursor or Codex",
2962
+ "group": "AI tools & CLI",
2963
+ "section": "Next steps",
2964
+ "url": "https://straitlink.in/docs/mcp/#next-steps",
2965
+ "markdown": "- [Set up an app from the terminal with `strait init`](https://straitlink.in/docs/cli/)\n- [Keys: publishable, secret and rotation](https://straitlink.in/docs/keys/)"
2966
+ },
2967
+ {
2968
+ "page": "strait init: set up deep links from the terminal",
2969
+ "group": "AI tools & CLI",
2970
+ "section": null,
2971
+ "url": "https://straitlink.in/docs/cli/",
2972
+ "markdown": "`strait init` is a command-line tool, `@straitlink/cli` on npm (0.1.1, published 15 October 2026). It looks at your app's files, writes one setup file with your key and link address filled in, and prints the rest of the setup as a checklist. It never edits a file you already have.\n\nFour steps. Each one says what to do and what you should see."
2973
+ },
2974
+ {
2975
+ "page": "strait init: set up deep links from the terminal",
2976
+ "group": "AI tools & CLI",
2977
+ "section": "1. Get your key and link address",
2978
+ "url": "https://straitlink.in/docs/cli/#1-get-your-key-and-link-address",
2979
+ "markdown": "**Do this:** open **Dashboard → Get started**. Copy two things: your **publishable key** (starts with `st_pub_live_…`) and your link address (like `hilltop.strait.link`).\n\n**You should see:** both on that page. Use the publishable key, never a secret key: the tool refuses a secret key, because it would end up inside your app."
2980
+ },
2981
+ {
2982
+ "page": "strait init: set up deep links from the terminal",
2983
+ "group": "AI tools & CLI",
2984
+ "section": "2. Look before you write",
2985
+ "url": "https://straitlink.in/docs/cli/#2-look-before-you-write",
2986
+ "markdown": "**Do this:** open a terminal in your app's folder (the one with `package.json` or `pubspec.yaml`) and paste this. Put in your key and address.\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link --dry-run\n```\n\n`--dry-run` means \"show me, change nothing\".\n\n**You should see:** `Found: React Native` (or Expo, Flutter or Android), the file it would create with your key in it, and a numbered list under `Next steps:`. Nothing in your folder has changed."
2987
+ },
2988
+ {
2989
+ "page": "strait init: set up deep links from the terminal",
2990
+ "group": "AI tools & CLI",
2991
+ "section": "3. Write the file",
2992
+ "url": "https://straitlink.in/docs/cli/#3-write-the-file",
2993
+ "markdown": "**Do this:** run the same command without `--dry-run`:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\n| Your app | The file it writes |\n|---|---|\n| React Native | `links.ts` (or `links.js`) |\n| Expo | `links.ts`, plus `app/+native-intent.tsx` if you use Expo Router |\n| Flutter | `lib/links.dart` |\n| Android (Kotlin) | `app/src/main/java/<your package>/StraitLinks.kt` |\n\n**You should see:** the new file in your folder. It creates the Strait client and has one `onLink` handler where every link arrives."
2994
+ },
2995
+ {
2996
+ "page": "strait init: set up deep links from the terminal",
2997
+ "group": "AI tools & CLI",
2998
+ "section": "4. Do the printed steps",
2999
+ "url": "https://straitlink.in/docs/cli/#4-do-the-printed-steps",
3000
+ "markdown": "**Do this:** follow the `Next steps:` list from top to bottom. It has your exact install command, the Android intent filters with your scheme (or the `app.json` block for Expo), the iPhone Associated Domains entry, what to fill in on the dashboard, and two `curl` checks for your verification files. Steps your project already has are marked `[already done]`. Where the list says **Dashboard → Settings**, those boxes are now in **Dashboard → Apps & stores**.\n\n**You should see:** after the last step, a line starting `Strait link:` in your app's log when you open the test link the list gives you."
3001
+ },
3002
+ {
3003
+ "page": "strait init: set up deep links from the terminal",
3004
+ "group": "AI tools & CLI",
3005
+ "section": "Options",
3006
+ "url": "https://straitlink.in/docs/cli/#options",
3007
+ "markdown": "| Option | What it does |\n|---|---|\n| `--key` | Your publishable key. Or set `STRAIT_PUBLISHABLE_KEY` |\n| `--host` | Your link address, like `hilltop.strait.link`. A pasted link works too. Or set `STRAIT_LINK_HOST` |\n| `--scheme` | Your app's own scheme, like `hilltopshop`. Guessed from `app.json` or the package name if you leave it out |\n| `--framework` | `react-native`, `expo`, `flutter` or `android`, to skip detection |\n| `--dry-run` | Show the files and steps; write nothing |\n| `--force` | Replace a setup file an earlier run made |\n| `--yes` | Never ask a question; stop with exit code 2 if something is missing (for CI and AI agents) |\n\nRun it with no flags in a terminal and it asks for the key and address instead. Exit codes: `0` done, `2` something to fix (the message says what), `1` an unexpected error.\n\nThe install line it prints is the SDK version it was written for (React Native 0.9.1, Flutter 0.9.2, Android 0.8.0). Those work. The newest are React Native 0.9.2, Flutter 0.9.3 and Android 0.8.1; to use them, change the version in the printed command."
3008
+ },
3009
+ {
3010
+ "page": "strait init: set up deep links from the terminal",
3011
+ "group": "AI tools & CLI",
3012
+ "section": "Common errors",
3013
+ "url": "https://straitlink.in/docs/cli/#common-errors",
3014
+ "markdown": "- **`Missing --key and --host`.** You ran it with `--yes` or outside a terminal. Pass both flags as in step 2.\n- **`Not overwriting links.ts`** (or another file). An earlier run made it. Keep it, or add `--force` to write it again.\n- **It finds the wrong kind of app.** Add `--framework react-native` (or `expo`, `flutter`, `android`)."
3015
+ },
3016
+ {
3017
+ "page": "strait init: set up deep links from the terminal",
3018
+ "group": "AI tools & CLI",
3019
+ "section": "Next steps",
3020
+ "url": "https://straitlink.in/docs/cli/#next-steps",
3021
+ "markdown": "- [Quickstart: see a link open your app](https://straitlink.in/docs/quick-start/)\n- [Use Strait from Claude, Cursor or Codex (MCP server)](https://straitlink.in/docs/mcp/)"
3022
+ },
2875
3023
  {
2876
3024
  "page": "Troubleshooting",
2877
3025
  "group": "Help",
2878
3026
  "section": "The link opens the browser instead of the app",
2879
3027
  "url": "https://straitlink.in/docs/troubleshooting/#the-link-opens-the-browser-instead-of-the-app",
2880
- "markdown": "This almost always means the phone couldn't verify that your app may open links on your domain.\n\nStart with the free [App Links checker](https://straitlink.in/tools/app-links-checker/): it fetches both files from your domain and explains what's wrong with them (missing file, redirect, wrong content type, malformed fingerprint or app ID).\n\n1. **Check the file.** `curl https://hilltop.strait.link/.well-known/assetlinks.json` should list your package name and fingerprints. A `404` means the Android fields in Apps & stores are empty.\n2. **Check the SHA-256.** The fingerprint must match the certificate of the build on the phone:\n - Debug builds: your debug keystore.\n - Release builds you install yourself: your upload key.\n - Builds from Google Play: the **App signing key** in Play Console → App integrity. This is Google's key, not yours.\n3. **Check the intent filter**: `android:autoVerify=\"true\"`, `https` scheme, and `android:host=\"hilltop.strait.link\"` exactly.\n4. **Re-verify.** Android checks at install time. Reinstall the app, then run `adb shell pm get-app-links com.yourco.app`. You want `verified`.\n5. **Tap, don't type.** Pasting a link into the address bar never opens an app. Test from Messages, WhatsApp or Gmail.\n\nOn iPhone (beta): check `apple-app-site-association` lists `<Team ID>.<bundle ID>`, and the app has `applinks:hilltop.strait.link` in Associated Domains. iOS caches the file through Apple's CDN, so changes can take a while to show."
3028
+ "markdown": "This almost always means the phone couldn't verify that your app may open links on your domain.\n\nStart with the free [App Links checker](https://straitlink.in/tools/app-links-checker/): it fetches both files from your domain and explains what's wrong with them (missing file, redirect, wrong content type, malformed fingerprint or app ID).\n\n1. **Check the file.** `curl https://hilltop.strait.link/.well-known/assetlinks.json` should list your package name and fingerprints. A `404` means the Android fields in Apps & stores are empty.\n2. **Check the SHA-256.** The fingerprint must match the certificate of the build on the phone:\n - Debug builds: your debug keystore.\n - Release builds you install yourself: your upload key.\n - Builds from Google Play: the **App signing key** in Play Console → App integrity. This is Google's key, not yours.\n3. **Check the intent filter**: `android:autoVerify=\"true\"`, `https` scheme, and `android:host=\"hilltop.strait.link\"` exactly.\n4. **Re-verify.** Android checks at install time. Reinstall the app, then run `adb shell pm get-app-links com.yourco.app`. You want `verified`.\n5. **Tap, don't type.** Pasting a link into the address bar never opens an app. Test from Messages, WhatsApp or Gmail.\n\nOn iPhone: check `apple-app-site-association` lists `<Team ID>.<bundle ID>`, and the app has `applinks:hilltop.strait.link` in Associated Domains. iOS caches the file through Apple's CDN, so changes can take a while to show."
2881
3029
  },
2882
3030
  {
2883
3031
  "page": "Troubleshooting",
@@ -2905,7 +3053,7 @@
2905
3053
  "group": "Help",
2906
3054
  "section": "The deferred link didn't open the right screen after install",
2907
3055
  "url": "https://straitlink.in/docs/troubleshooting/#the-deferred-link-didnt-open-the-right-screen-after-install",
2908
- "markdown": "- **Android**: the app must be installed **from Google Play** (internal testing is fine). Sideloaded builds have no install referrer. In Settings, set \"When someone without the app taps a link\" to **Always Google Play** while your app isn't public, otherwise we send people to your website to avoid Play's \"Item not found\" page.\n- **iPhone (beta)**: the person must tap **Get the app** on our page in Safari, and open the app within an hour, on the same network. A different network (Wi-Fi to cellular) will usually prevent a match.\n- **Both**: the deferred check runs once per install. Uninstall and reinstall to test again."
3056
+ "markdown": "- **Android**: the app must be installed **from Google Play** (internal testing is fine). Sideloaded builds have no install referrer. In Settings, set \"When someone without the app taps a link\" to **Always Google Play** while your app isn't public, otherwise we send people to your website to avoid Play's \"Item not found\" page.\n- **iPhone (opt-in)**: device matching must be on in Settings → iPhone installs, and the person must tap **Get the app** on our page in Safari, and open the app within an hour, on the same network. A different network (Wi-Fi to cellular) will usually prevent a match.\n- **Both**: the deferred check runs once per install. Uninstall and reinstall to test again."
2909
3057
  },
2910
3058
  {
2911
3059
  "page": "Troubleshooting",
@@ -2933,7 +3081,7 @@
2933
3081
  "group": "Help",
2934
3082
  "section": "Why it exists",
2935
3083
  "url": "https://straitlink.in/docs/test-mode/#why-it-exists",
2936
- "markdown": "Seeing a deferred deep link work today takes a build installed from Google Play (the link travels in the Play Install Referrer) or, on iPhone (beta), a device match. Both are slow to set up for a first check. Test mode is a shortcut for one question only: *is my app wired up so that the first open lands on the link's screen?*"
3084
+ "markdown": "Seeing a deferred deep link work today takes a build installed from Google Play (the link travels in the Play Install Referrer) or, on iPhone, a device match (opt-in). Both are slow to set up for a first check. Test mode is a shortcut for one question only: *is my app wired up so that the first open lands on the link's screen?*"
2937
3085
  },
2938
3086
  {
2939
3087
  "page": "Test mode for deferred deep links (coming soon)",