@straitlink/mcp 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,2960 @@
1
+ {
2
+ "source": "https://straitlink.in",
3
+ "builtFrom": "site/content/docs",
4
+ "sections": [
5
+ {
6
+ "page": "What is Strait?",
7
+ "group": "Get started",
8
+ "section": "A normal link vs a Strait link",
9
+ "url": "https://straitlink.in/docs/#a-normal-link-vs-a-strait-link",
10
+ "markdown": "| Someone taps your link… | A normal link | A Strait link |\n|---|---|---|\n| App installed | Opens your website, or the app's home screen | Opens the app on the right screen |\n| App not installed | Opens the website. The screen is lost after install | Sends them to the store, then opens that screen on first launch |\n| On a computer | Opens the website | Opens the website (or a page you choose) |\n\nOpening a screen in an installed app is a **deep link**. Keeping that screen through an install is a **deferred deep link**. [How both work](https://straitlink.in/docs/deferred-deep-linking/)."
11
+ },
12
+ {
13
+ "page": "What is Strait?",
14
+ "group": "Get started",
15
+ "section": "How it fits together",
16
+ "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
+ },
19
+ {
20
+ "page": "What is Strait?",
21
+ "group": "Get started",
22
+ "section": "Start here",
23
+ "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
+ },
26
+ {
27
+ "page": "Quickstart: open your app from a link (Android)",
28
+ "group": "Get started",
29
+ "section": null,
30
+ "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
+ },
33
+ {
34
+ "page": "Quickstart: open your app from a link (Android)",
35
+ "group": "Get started",
36
+ "section": "1. Create a workspace",
37
+ "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
+ },
40
+ {
41
+ "page": "Quickstart: open your app from a link (Android)",
42
+ "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."
46
+ },
47
+ {
48
+ "page": "Quickstart: open your app from a link (Android)",
49
+ "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`)."
53
+ },
54
+ {
55
+ "page": "Quickstart: open your app from a link (Android)",
56
+ "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."
60
+ },
61
+ {
62
+ "page": "Quickstart: open your app from a link (Android)",
63
+ "group": "Get started",
64
+ "section": "4. Let your app answer to its scheme",
65
+ "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
+ },
68
+ {
69
+ "page": "Quickstart: open your app from a link (Android)",
70
+ "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."
74
+ },
75
+ {
76
+ "page": "Quickstart: open your app from a link (Android)",
77
+ "group": "Get started",
78
+ "section": "6. Tap the link",
79
+ "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
+ },
82
+ {
83
+ "page": "Quickstart: open your app from a link (Android)",
84
+ "group": "Get started",
85
+ "section": "Common errors",
86
+ "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
+ },
89
+ {
90
+ "page": "Quickstart: open your app from a link (Android)",
91
+ "group": "Get started",
92
+ "section": "Next steps",
93
+ "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
+ },
96
+ {
97
+ "page": "Go-live checklist",
98
+ "group": "Get started",
99
+ "section": "1. Add every SHA-256 fingerprint",
100
+ "url": "https://straitlink.in/docs/production-checklist/#1-add-every-sha-256-fingerprint",
101
+ "markdown": "Android opens your app directly from a link only if your link domain lists the certificate the installed build was signed with. Add all three in **Dashboard → Apps & stores**:\n\n| Build | Certificate | Where to find it |\n|---|---|---|\n| Local debug builds | Debug key | The commands below |\n| Release builds you install yourself | Upload key | Your keystore, or `eas credentials` |\n| Anything installed from Google Play | **Play App Signing** key (Google's, not yours) | Play Console → Test and release → App integrity |\n\n```bash\n# Native Android project (Android Studio's shared debug key)\nkeytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android | grep SHA256\n\n# React Native, or Expo after `npx expo prebuild`: the project's own debug key\nkeytool -list -v -keystore android/app/debug.keystore -storepass android | grep SHA256\n\n# EAS Build: choose Android and your build profile; the keystore's SHA-256 is listed\neas credentials\n```\n\nPaste them in any form: upper or lower case, with or without colons. We store them as uppercase pairs joined by colons and refuse anything that isn't a SHA-256 fingerprint.\n\n**You should see:** your package name and every fingerprint in the file we host:\n\n```bash\ncurl https://hilltop.strait.link/.well-known/assetlinks.json\n```"
102
+ },
103
+ {
104
+ "page": "Go-live checklist",
105
+ "group": "Get started",
106
+ "section": "2. Verify App Links on Android",
107
+ "url": "https://straitlink.in/docs/production-checklist/#2-verify-app-links-on-android",
108
+ "markdown": "Add a second intent filter next to the custom scheme one from the quickstart:\n\n```xml title=\"AndroidManifest.xml\"\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\nReinstall the app (Android checks at install time), then ask the phone:\n\n```bash\nadb shell pm get-app-links com.yourcompany.yourapp\n```\n\n**You should see:** `hilltop.strait.link: verified`. Tapping a link now opens the app with no browser flash. If not, paste your domain into the [App Links checker](https://straitlink.in/tools/app-links-checker/)."
109
+ },
110
+ {
111
+ "page": "Go-live checklist",
112
+ "group": "Get started",
113
+ "section": "3. Test a deferred link from Google Play",
114
+ "url": "https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play",
115
+ "markdown": "A link tapped before the app is installed reaches the app through the Google Play Install Referrer, so it only works for builds installed from Google Play:\n\n1. Upload a build to **internal testing** in Play Console and add yourself as a tester.\n2. In **Dashboard → Apps & stores → People without the app**, choose **Always Google Play** while the app isn't public. (The default sends people to your website when the Play listing isn't live, to avoid Play's \"Item not found\" page.)\n3. Uninstall the app, tap your link, install from Play, open the app.\n\nThe first upload may be reviewed before testers can install it. Google says reviews can take up to 7 days, or longer in exceptional cases ([Play Console Help](https://support.google.com/googleplay/android-developer/answer/9859751)).\n\n**You should see:** the first open lands on `/product/42`, and **Analytics** shows the tap, the install with match method `install_referrer`, and the open."
116
+ },
117
+ {
118
+ "page": "Go-live checklist",
119
+ "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."
123
+ },
124
+ {
125
+ "page": "Go-live checklist",
126
+ "group": "Get started",
127
+ "section": "5. Put keys and links in the right place",
128
+ "url": "https://straitlink.in/docs/production-checklist/#5-put-keys-and-links-in-the-right-place",
129
+ "markdown": "- **Secret key**: on your server only, in an environment variable. Create links from your backend with the [Node SDK](https://straitlink.in/docs/sdks/node/) or the [REST API](https://straitlink.in/docs/api/), never from the app. See [Keys](https://straitlink.in/docs/keys/).\n- **Link pages**: add your app name, icon and colour in **Settings → How your link pages look**, so the pages people see lead with your brand ([App Links](https://straitlink.in/docs/app-links/#how-your-link-pages-look)).\n- **No website?** A link's destination can be any `https://` URL on a domain you control, such as `https://yourapp.com/product/42`. Only the path and query reach your app. Desktop visitors go to the full URL, and so do people without the app if you chose \"always your website\", so make sure that page exists.\n- **SMS in India**: whitelist your link domain, `<handle>.strait.link`, in your DLT template like any other URL.\n\n**You should see:** no secret key anywhere in your app's source or build output (`grep -r st_live_ .` from the app folder finds nothing)."
130
+ },
131
+ {
132
+ "page": "Go-live checklist",
133
+ "group": "Get started",
134
+ "section": "Common errors",
135
+ "url": "https://straitlink.in/docs/production-checklist/#common-errors",
136
+ "markdown": "- **Works on a debug build, opens the browser from Play.** The Play App Signing fingerprint is missing (step 1).\n- **The deferred link opens the home screen.** The build was sideloaded, or the app was opened before you tapped the link. The check runs once per install: uninstall and try again.\n- **`assetlinks.json` returns 404.** The package name or fingerprints in **Apps & stores** are empty. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
137
+ },
138
+ {
139
+ "page": "Go-live checklist",
140
+ "group": "Get started",
141
+ "section": "Next steps",
142
+ "url": "https://straitlink.in/docs/production-checklist/#next-steps",
143
+ "markdown": "- [Track events and revenue](https://straitlink.in/docs/sdks/react-native/events/): put purchases on the tap that brought them.\n- [Analytics: how we count](https://straitlink.in/docs/analytics/): what taps, opens and installs mean in your dashboard."
144
+ },
145
+ {
146
+ "page": "Migrate from Firebase Dynamic Links",
147
+ "group": "Get started",
148
+ "section": null,
149
+ "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
+ },
152
+ {
153
+ "page": "Migrate from Firebase Dynamic Links",
154
+ "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."
158
+ },
159
+ {
160
+ "page": "Migrate from Firebase Dynamic Links",
161
+ "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."
165
+ },
166
+ {
167
+ "page": "Migrate from Firebase Dynamic Links",
168
+ "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."
172
+ },
173
+ {
174
+ "page": "Migrate from Firebase Dynamic Links",
175
+ "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."
179
+ },
180
+ {
181
+ "page": "Migrate from Firebase Dynamic Links",
182
+ "group": "Get started",
183
+ "section": "Common errors",
184
+ "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."
186
+ },
187
+ {
188
+ "page": "Migrate from Firebase Dynamic Links",
189
+ "group": "Get started",
190
+ "section": "Next steps",
191
+ "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."
193
+ },
194
+ {
195
+ "page": "Move your Firebase links in one step",
196
+ "group": "Get started",
197
+ "section": "In plain words",
198
+ "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."
200
+ },
201
+ {
202
+ "page": "Move your Firebase links in one step",
203
+ "group": "Get started",
204
+ "section": "1. Enter your old domain",
205
+ "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."
207
+ },
208
+ {
209
+ "page": "Move your Firebase links in one step",
210
+ "group": "Get started",
211
+ "section": "2. Fill in the links that need you",
212
+ "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."
214
+ },
215
+ {
216
+ "page": "Move your Firebase links in one step",
217
+ "group": "Get started",
218
+ "section": "3. Keep old links opening your app",
219
+ "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."
221
+ },
222
+ {
223
+ "page": "Move your Firebase links in one step",
224
+ "group": "Get started",
225
+ "section": "When it pauses",
226
+ "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#when-it-pauses",
227
+ "markdown": "| Message | Why | What happens |\n|---|---|---|\n| Today's reads from Google are used | We read Google about once a second and at most 500 times a day per workspace | It resumes by itself at 00:00 UTC |\n| Add your iOS Team ID + bundle ID or Android package | We only read a domain whose app-link files name **your** app | It continues by itself once they're saved |\n| Your plan's link limit is reached | New links need room on your plan | We try again every hour |"
228
+ },
229
+ {
230
+ "page": "Move your Firebase links in one step",
231
+ "group": "Get started",
232
+ "section": "For developers: what the engine does",
233
+ "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."
235
+ },
236
+ {
237
+ "page": "Move your Firebase links in one step",
238
+ "group": "Get started",
239
+ "section": "Common errors",
240
+ "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#common-errors",
241
+ "markdown": "- **\"This old domain doesn't name your app\".** The Team ID, bundle ID or package in Apps & stores doesn't match what Google serves for that domain. Fix them there, then press **Try again where it can help**.\n- **Only a few links found.** The Internet Archive saved only some of them. Add your own list or export in step 1; nothing is duplicated.\n- **A custom domain's links all need you.** Google's debug page only exists for page.link. Upload a file with destinations for those links."
242
+ },
243
+ {
244
+ "page": "Move your Firebase links in one step",
245
+ "group": "Get started",
246
+ "section": "Next steps",
247
+ "url": "https://straitlink.in/docs/migrate-from-firebase/one-step/#next-steps",
248
+ "markdown": "- [Open your app from old page.link links](https://straitlink.in/docs/migrate-from-firebase/page-link/): keep old links opening your app.\n- [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/): switch your backend to Strait and test on a real phone."
249
+ },
250
+ {
251
+ "page": "Migrate from Firebase: swap the SDK",
252
+ "group": "Get started",
253
+ "section": null,
254
+ "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/)) |"
256
+ },
257
+ {
258
+ "page": "Migrate from Firebase: swap the SDK",
259
+ "group": "Get started",
260
+ "section": "1. Android (Kotlin)",
261
+ "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."
263
+ },
264
+ {
265
+ "page": "Migrate from Firebase: swap the SDK",
266
+ "group": "Get started",
267
+ "section": "2. React Native",
268
+ "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."
270
+ },
271
+ {
272
+ "page": "Migrate from Firebase: swap the SDK",
273
+ "group": "Get started",
274
+ "section": "3. Flutter",
275
+ "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`."
277
+ },
278
+ {
279
+ "page": "Migrate from Firebase: swap the SDK",
280
+ "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."
284
+ },
285
+ {
286
+ "page": "Migrate from Firebase: swap the SDK",
287
+ "group": "Get started",
288
+ "section": "5. Unity and the web",
289
+ "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."
291
+ },
292
+ {
293
+ "page": "Migrate from Firebase: swap the SDK",
294
+ "group": "Get started",
295
+ "section": "6. Replace in-app link creation",
296
+ "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/)."
298
+ },
299
+ {
300
+ "page": "Migrate from Firebase: swap the SDK",
301
+ "group": "Get started",
302
+ "section": "Common errors",
303
+ "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#common-errors",
304
+ "markdown": "- **Both SDKs handle the same link.** Remove the Dynamic Links dependency completely; leftover listeners can swallow the intent.\n- **The app still opens old `page.link` URLs in the browser.** Expected when the build dropped the page.link domain or Google stopped serving its app-link files: some still do a plain redirect to the old fallback. Keep the domain to open the app for people who have it ([how](https://straitlink.in/docs/migrate-from-firebase/page-link/)), and recreate the links on your domain ([Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/)).\n- **Routing breaks on the full URL.** Strait gives you `e.path` already split from the host. Route on the path."
305
+ },
306
+ {
307
+ "page": "Migrate from Firebase: swap the SDK",
308
+ "group": "Get started",
309
+ "section": "Next steps",
310
+ "url": "https://straitlink.in/docs/migrate-from-firebase/sdk/#next-steps",
311
+ "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
+ },
313
+ {
314
+ "page": "Migrate from Firebase: import links and cut over",
315
+ "group": "Get started",
316
+ "section": "1. Collect your old links",
317
+ "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."
319
+ },
320
+ {
321
+ "page": "Migrate from Firebase: import links and cut over",
322
+ "group": "Get started",
323
+ "section": "2. Import them in the dashboard",
324
+ "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**."
326
+ },
327
+ {
328
+ "page": "Migrate from Firebase: import links and cut over",
329
+ "group": "Get started",
330
+ "section": "3. Or import them with the batch API",
331
+ "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`."
333
+ },
334
+ {
335
+ "page": "Migrate from Firebase: import links and cut over",
336
+ "group": "Get started",
337
+ "section": "4. Switch your backend to POST /v1/links",
338
+ "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**."
340
+ },
341
+ {
342
+ "page": "Migrate from Firebase: import links and cut over",
343
+ "group": "Get started",
344
+ "section": "5. Check the whole path on a real phone",
345
+ "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**."
347
+ },
348
+ {
349
+ "page": "Migrate from Firebase: import links and cut over",
350
+ "group": "Get started",
351
+ "section": "6. Replace the links people see",
352
+ "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."
354
+ },
355
+ {
356
+ "page": "Migrate from Firebase: import links and cut over",
357
+ "group": "Get started",
358
+ "section": "Common errors",
359
+ "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#common-errors",
360
+ "markdown": "- **Imported links open the website, not the app.** The app update with your new link domain isn't installed yet, or your SHA-256 fingerprints are missing ([go-live checklist](https://straitlink.in/docs/production-checklist/)).\n- **`402` while importing.** The import is past your plan's monthly link limit. Links created so far keep working; upgrade or import the rest next month.\n- **`409` for some slugs.** Those slugs already exist in your workspace (often from an earlier run). That's safe to ignore."
361
+ },
362
+ {
363
+ "page": "Migrate from Firebase: import links and cut over",
364
+ "group": "Get started",
365
+ "section": "Next steps",
366
+ "url": "https://straitlink.in/docs/migrate-from-firebase/cutover/#next-steps",
367
+ "markdown": "- [Analytics: how we count](https://straitlink.in/docs/analytics/): how taps, opens and installs compare with the Firebase numbers you had.\n- [Webhooks](https://straitlink.in/docs/webhooks/): get installs and opens on your server, which Dynamic Links never offered."
368
+ },
369
+ {
370
+ "page": "Open your app from old page.link links",
371
+ "group": "Get started",
372
+ "section": "What nobody can do",
373
+ "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**."
375
+ },
376
+ {
377
+ "page": "Open your app from old page.link links",
378
+ "group": "Get started",
379
+ "section": "What still works, and why",
380
+ "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#what-still-works-and-why",
381
+ "markdown": "Google still serves page.link's `apple-app-site-association` and `assetlinks.json` with each customer's app ID (checked on 9 October 2026 for taxfix, mybillbook, okcredit, adda247 and fabulaapp). So on a phone whose app build still lists your page.link domain, iOS and Android open the app on a tap **without asking Google's redirect**. The old Firebase SDK then asks Google what the link means and gets nothing. The Strait SDK can answer instead:\n\n- a **short link** (`https://yourapp.page.link/aBcD`) is looked up in your workspace by its old host and code;\n- a **long link** (`https://yourapp.page.link/?link=https://…&apn=…`) carries its destination inside the URL, so the SDK reads `link=` on the phone, with no network call.\n\nThis depends on things outside our control:\n\n- **Google keeps serving those files.** It does today. Nothing says for how long. When it stops, the OS stops opening your app from page.link and these links go back to whatever Google answers.\n- **The person updates your app.** A build that still has the Firebase SDK opens the app and then shows nothing. Only builds with the Strait SDK benefit.\n- **Untested on real phones.** This path is covered by unit tests in every SDK. It has **not** been tested on a real iPhone yet, and the Android real-phone test is also still pending. Test it on your own devices before you rely on it."
382
+ },
383
+ {
384
+ "page": "Open your app from old page.link links",
385
+ "group": "Get started",
386
+ "section": "1. Keep the page.link domain in your next build",
387
+ "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."
389
+ },
390
+ {
391
+ "page": "Open your app from old page.link links",
392
+ "group": "Get started",
393
+ "section": "2. Add the host to linkHosts",
394
+ "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."
396
+ },
397
+ {
398
+ "page": "Open your app from old page.link links",
399
+ "group": "Get started",
400
+ "section": "3. Import your old codes",
401
+ "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."
403
+ },
404
+ {
405
+ "page": "Open your app from old page.link links",
406
+ "group": "Get started",
407
+ "section": "4. Test on your own phones",
408
+ "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."
410
+ },
411
+ {
412
+ "page": "Open your app from old page.link links",
413
+ "group": "Get started",
414
+ "section": "Common errors",
415
+ "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#common-errors",
416
+ "markdown": "- **The app opens and shows nothing.** The build still runs the Firebase SDK, or `yourapp.page.link` is missing from `linkHosts`.\n- **The browser opens instead of the app.** The build dropped the page.link domain, or Google no longer serves its app-link files for your domain (check step 1).\n- **`not_found` on a short link.** That code was never imported. Import it, or recover it while Google still answers."
417
+ },
418
+ {
419
+ "page": "Open your app from old page.link links",
420
+ "group": "Get started",
421
+ "section": "Next steps",
422
+ "url": "https://straitlink.in/docs/migrate-from-firebase/page-link/#next-steps",
423
+ "markdown": "- [Import and cut over](https://straitlink.in/docs/migrate-from-firebase/cutover/): put the same links on your own link domain and replace page.link wherever you control the text.\n- Used your own domain with Firebase Dynamic Links? Import your old links with the same paths. You can send that domain's traffic to Strait today with a redirect in your Firebase Hosting settings. Custom domains on Strait are coming soon; then you point the domain at us and the old paths work as before."
424
+ },
425
+ {
426
+ "page": "Migrate from Branch",
427
+ "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` |"
431
+ },
432
+ {
433
+ "page": "Migrate from Branch",
434
+ "group": "Get started",
435
+ "section": "1. Export your links from Branch",
436
+ "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```"
438
+ },
439
+ {
440
+ "page": "Migrate from Branch",
441
+ "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."
445
+ },
446
+ {
447
+ "page": "Migrate from Branch",
448
+ "group": "Get started",
449
+ "section": "3. Import",
450
+ "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)."
452
+ },
453
+ {
454
+ "page": "Migrate from Branch",
455
+ "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/)."
459
+ },
460
+ {
461
+ "page": "Migrate from Branch",
462
+ "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`."
466
+ },
467
+ {
468
+ "page": "Migrate from Branch",
469
+ "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."
473
+ },
474
+ {
475
+ "page": "Deferred deep links in React Native, end to end",
476
+ "group": "Recipes",
477
+ "section": null,
478
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/",
479
+ "markdown": "This page strings the separate React Native pages into one path for the case that matters most to growth teams: a new user. Each step links to the page with more detail. The same path for other stacks: [Expo](https://straitlink.in/docs/recipes/expo-deferred-deep-links/) · [Flutter](https://straitlink.in/docs/recipes/flutter-deferred-deep-links/)."
480
+ },
481
+ {
482
+ "page": "Deferred deep links in React Native, end to end",
483
+ "group": "Recipes",
484
+ "section": "1. Make a link for one screen",
485
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#1-make-a-link-for-one-screen",
486
+ "markdown": "In **Dashboard → Links → New link**, set the slug `summer` and the destination `https://shop.example.com/product/42?ref=SUMMER`. Or from your server:\n\n```bash\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?ref=SUMMER\", \"campaign\": \"summer_2026\" }'\n```\n\nYour app receives the destination's path and query (`/product/42`, `{ ref: 'SUMMER' }`), so use paths your app already routes on.\n\n**You should see:** the link `https://hilltop.strait.link/summer` in the Links list."
487
+ },
488
+ {
489
+ "page": "Deferred deep links in React Native, end to end",
490
+ "group": "Recipes",
491
+ "section": "2. Install the SDK with the Play Install Referrer",
492
+ "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`."
494
+ },
495
+ {
496
+ "page": "Deferred deep links in React Native, end to end",
497
+ "group": "Recipes",
498
+ "section": "3. Route every link, including the deferred one",
499
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#3-route-every-link-including-the-deferred-one",
500
+ "markdown": "The deferred link arrives through the same `onLink` callback as any other link, once, on the first launch after the install. `onLink` also replays events from before you subscribed, so subscribing when the navigator is ready never misses it.\n\nFirst, one file that maps a link's path to a screen (the same helper as [Receive links](https://straitlink.in/docs/sdks/react-native/receive-links/); skip it if you already have it):\n\n```tsx title=\"navigation.ts\"\nimport { createNavigationContainerRef } from '@react-navigation/native';\n\nexport const navigationRef = createNavigationContainerRef<any>();\n\n// Map a link's path to one of your screens.\nexport function openPath(path: string, params: Record<string, string> = {}) {\n const product = /^\\/product\\/([^/]+)$/.exec(path);\n if (product) navigationRef.navigate('Product', { id: product[1], ...params });\n else navigationRef.navigate('Home');\n}\n```\n\nThen subscribe when the navigator is ready:\n\n```tsx title=\"App.tsx\"\nimport { NavigationContainer } from '@react-navigation/native';\nimport { links } from './links';\nimport { navigationRef, openPath } from './navigation';\n\nexport default function App() {\n return (\n <NavigationContainer\n ref={navigationRef}\n onReady={() => links.onLink((e) => {\n if (e.kind === 'deferred') console.log('Strait first open:', e.route, e.matched, e.reason);\n if (e.matched && e.path) openPath(e.path, e.params);\n })}\n >\n {/* your navigator */}\n </NavigationContainer>\n );\n}\n```\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens the Product screen. This proves the routing; the next steps prove the install."
501
+ },
502
+ {
503
+ "page": "Deferred deep links in React Native, end to end",
504
+ "group": "Recipes",
505
+ "section": "4. Decide what a new user sees first",
506
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#4-decide-what-a-new-user-sees-first",
507
+ "markdown": "On the first launch the SDK always answers once with `kind: 'deferred'`. When `matched` is `false`, `reason` is `no_match` (most installs don't come from a link) or `network` (the check runs again on the next launch). Show your usual onboarding in both cases, and skip it when a link matched:\n\n```ts title=\"firstOpen.ts\"\nimport { links } from './links';\n\n// Resolves with the link's path for a new user who came from a link, or null.\nexport const firstOpenPath = new Promise<string | null>((resolve) => {\n const off = links.onLink((e) => {\n if (e.kind !== 'deferred') return;\n off();\n resolve(e.matched && e.path ? e.path : null);\n });\n});\n```\n\nKeep a short loading screen while you wait. [Receive links, step 3](https://straitlink.in/docs/sdks/react-native/receive-links/#3-show-a-loading-state-optional) has one.\n\n**You should see:** a fresh debug install, with no link tapped, logs `Strait first open: fingerprint false no_match` and shows your onboarding."
508
+ },
509
+ {
510
+ "page": "Deferred deep links in React Native, end to end",
511
+ "group": "Recipes",
512
+ "section": "5. Ship a Google Play internal-testing build",
513
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#5-ship-a-google-play-internal-testing-build",
514
+ "markdown": "A build from Android Studio has no install referrer, so this test needs a build installed from Google Play:\n\n1. Upload a release build to **internal testing** in Play Console and add yourself as a tester.\n2. In **Dashboard → Apps & stores → People without the app**, choose **Always Google Play** while the app isn't public. (The default sends people to your website while the Play listing isn't live.)\n\n**You should see:** the build listed as available to testers in Play Console. Google says the first review can take up to 7 days, or longer in exceptional cases ([Play Console Help](https://support.google.com/googleplay/android-developer/answer/9859751))."
515
+ },
516
+ {
517
+ "page": "Deferred deep links in React Native, end to end",
518
+ "group": "Recipes",
519
+ "section": "6. Tap, install, open",
520
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#6-tap-install-open",
521
+ "markdown": "1. Uninstall the app from the phone.\n2. Send yourself `https://hilltop.strait.link/summer` in WhatsApp or Messages and tap it. Google Play opens on your app.\n3. Install from Play and open the app.\n\n**You should see:** the app opens directly on the Product screen for `42`, with `ref` `SUMMER` in its params, and `adb logcat` shows `Strait first open: install_referrer true`. The check runs once per install; uninstall to repeat it."
522
+ },
523
+ {
524
+ "page": "Deferred deep links in React Native, end to end",
525
+ "group": "Recipes",
526
+ "section": "7. See the install in Analytics",
527
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#7-see-the-install-in-analytics",
528
+ "markdown": "Open **Dashboard → Analytics** and choose `summer_2026` in the **Campaign** filter. To put revenue on the same tap, record a purchase from the app with `links.trackEvent('purchase', { value: 499, currency: 'INR' })` ([Events & revenue](https://straitlink.in/docs/sdks/react-native/events/)).\n\n**You should see:** your tap on `summer`, one install with match method `install_referrer` joined to that exact tap, and one app open from a new user. A test purchase appears under the same campaign."
529
+ },
530
+ {
531
+ "page": "Deferred deep links in React Native, end to end",
532
+ "group": "Recipes",
533
+ "section": "Common errors",
534
+ "url": "https://straitlink.in/docs/recipes/react-native-deferred-deep-links/#common-errors",
535
+ "markdown": "- **The first open lands on the home screen.** The app came from Android Studio or a shared APK, not Google Play, or the Apps & stores choice in step 5 still sends people to your website. [Test page](https://straitlink.in/docs/sdks/react-native/test/#4-test-a-deferred-link-first-open-after-install).\n- **Analytics shows the tap but no install.** The phone was offline on the first launch, so the report is saved and sent on a later launch. Open the app again on a network.\n- **The deferred event fires before the navigator exists.** Navigating from it crashes or does nothing. Subscribe in `onReady`, as in step 3."
536
+ },
537
+ {
538
+ "page": "Deferred deep links in React Native, end to end",
539
+ "group": "Recipes",
540
+ "section": "Next steps",
541
+ "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."
543
+ },
544
+ {
545
+ "page": "Deferred deep links in Expo, end to end",
546
+ "group": "Recipes",
547
+ "section": null,
548
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/",
549
+ "markdown": "This page strings the Expo and React Native pages into one path for the case that matters most to growth teams: a new user. Expo uses the same SDK as bare React Native; what changes is `app.json`, a development build instead of Expo Go, and EAS for the Play build. Bare React Native? Use the [React Native recipe](https://straitlink.in/docs/recipes/react-native-deferred-deep-links/)."
550
+ },
551
+ {
552
+ "page": "Deferred deep links in Expo, end to end",
553
+ "group": "Recipes",
554
+ "section": "1. Make a link for one screen",
555
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#1-make-a-link-for-one-screen",
556
+ "markdown": "In **Dashboard → Links → New link**, set the slug `summer` and the destination `https://shop.example.com/product/42?ref=SUMMER`. Or from your server:\n\n```bash\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?ref=SUMMER\", \"campaign\": \"summer_2026\" }'\n```\n\nYour app receives the destination's path and query (`/product/42`, `{ ref: 'SUMMER' }`), so use paths that match files in `app/`, here `app/product/[id].tsx`.\n\n**You should see:** the link `https://hilltop.strait.link/summer` in the Links list."
557
+ },
558
+ {
559
+ "page": "Deferred deep links in Expo, end to end",
560
+ "group": "Recipes",
561
+ "section": "2. Install the SDK and add app.json settings",
562
+ "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`."
564
+ },
565
+ {
566
+ "page": "Deferred deep links in Expo, end to end",
567
+ "group": "Recipes",
568
+ "section": "3. Route every link, including the deferred one",
569
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#3-route-every-link-including-the-deferred-one",
570
+ "markdown": "The deferred link arrives through the same `onLink` callback as any other link, once, on the first launch after the install. `onLink` replays events from before you subscribed, so subscribing when navigation is ready never misses it:\n\n```tsx title=\"app/_layout.tsx\"\nimport { Stack, router, useRootNavigationState, type Href } from 'expo-router';\nimport { useEffect } from 'react';\nimport { links } from '../links';\n\nexport default function RootLayout() {\n const ready = useRootNavigationState()?.key;\n useEffect(() => {\n if (!ready) return;\n return links.onLink((e) => {\n if (e.kind === 'deferred') console.log('Strait first open:', e.route, e.matched, e.reason);\n if (e.matched && e.path) router.push({ pathname: e.path, params: e.params } as Href);\n });\n }, [ready]);\n return <Stack />;\n}\n```\n\nAdd `app/+native-intent.tsx` from [Expo, step 3](https://straitlink.in/docs/sdks/expo/#3-route-links-with-expo-router) so Expo Router doesn't try to open the raw link URL itself. Then make a development build, because Expo Go can't load the native module: `npx expo run:android`.\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens `app/product/[id].tsx` with `id` `42`, and no \"Unmatched route\" screen. This proves the routing; the next steps prove the install."
571
+ },
572
+ {
573
+ "page": "Deferred deep links in Expo, end to end",
574
+ "group": "Recipes",
575
+ "section": "4. Decide what a new user sees first",
576
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#4-decide-what-a-new-user-sees-first",
577
+ "markdown": "On the first launch the SDK always answers once with `kind: 'deferred'`. When `matched` is `false`, `reason` is `no_match` (most installs don't come from a link) or `network` (the check runs again on the next launch). Show your usual onboarding in both cases, and skip it when a link matched:\n\n```ts title=\"firstOpen.ts\"\nimport { links } from './links';\n\n// The link's path for a new user who came from a link, or null.\n// Later launches have no deferred event, so the timer answers null.\nexport const firstOpenPath = new Promise<string | null>((resolve) => {\n const timer = setTimeout(() => { off(); resolve(null); }, 5000);\n const off = links.onLink((e) => {\n if (e.kind !== 'deferred') return;\n clearTimeout(timer);\n off();\n resolve(e.matched && e.path ? e.path : null);\n });\n});\n```\n\nKeep the splash screen up while you wait (`expo-splash-screen`'s `preventAutoHideAsync`, then `hideAsync`).\n\n**You should see:** a fresh development-build install, with no link tapped, logs `Strait first open: fingerprint false no_match` and shows your onboarding."
578
+ },
579
+ {
580
+ "page": "Deferred deep links in Expo, end to end",
581
+ "group": "Recipes",
582
+ "section": "5. Ship a Google Play internal-testing build",
583
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#5-ship-a-google-play-internal-testing-build",
584
+ "markdown": "A development build has no install referrer, so this test needs a build installed from Google Play:\n\n1. Run `eas build --platform android --profile production` and upload the `.aab` to **internal testing** in Play Console (by hand, or with `eas submit --platform android`). Add yourself as a tester.\n2. In **Dashboard → Apps & stores → People without the app**, choose **Always Google Play** while the app isn't public. (The default sends people to your website while the Play listing isn't live.)\n\n**You should see:** the build listed as available to testers in Play Console. Google says the first review can take up to 7 days, or longer in exceptional cases ([Play Console Help](https://support.google.com/googleplay/android-developer/answer/9859751))."
585
+ },
586
+ {
587
+ "page": "Deferred deep links in Expo, end to end",
588
+ "group": "Recipes",
589
+ "section": "6. Tap, install, open",
590
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#6-tap-install-open",
591
+ "markdown": "1. Uninstall the app from the phone.\n2. Send yourself `https://hilltop.strait.link/summer` in WhatsApp or Messages and tap it. Google Play opens on your app.\n3. Install from Play and open the app.\n\n**You should see:** the app opens directly on the Product screen for `42`, with `ref` `SUMMER` in its params, and `adb logcat | grep ReactNativeJS` shows `Strait first open: install_referrer true`. The check runs once per install; uninstall to repeat it."
592
+ },
593
+ {
594
+ "page": "Deferred deep links in Expo, end to end",
595
+ "group": "Recipes",
596
+ "section": "7. See the install in Analytics",
597
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#7-see-the-install-in-analytics",
598
+ "markdown": "Open **Dashboard → Analytics** and choose `summer_2026` in the **Campaign** filter. To put revenue on the same tap, record a purchase from the app with `links.trackEvent('purchase', { value: 499, currency: 'INR' })` ([Events & revenue](https://straitlink.in/docs/sdks/react-native/events/)).\n\n**You should see:** your tap on `summer`, one install with match method `install_referrer` joined to that exact tap, and one app open from a new user. A test purchase appears under the same campaign."
599
+ },
600
+ {
601
+ "page": "Deferred deep links in Expo, end to end",
602
+ "group": "Recipes",
603
+ "section": "Common errors",
604
+ "url": "https://straitlink.in/docs/recipes/expo-deferred-deep-links/#common-errors",
605
+ "markdown": "- **The first open lands on the home screen.** The app came from a development build or a shared APK, not Google Play, or the Apps & stores choice in step 5 still sends people to your website.\n- **\"Unmatched route\" after the install.** `app/+native-intent.tsx` is missing, so Expo Router tried to open `/summer` itself (step 3).\n- **Verified links open the browser in the Play build.** The Play App Signing key isn't in Apps & stores. Add it as in [Expo, step 5](https://straitlink.in/docs/sdks/expo/#5-add-your-sha-256-fingerprints)."
606
+ },
607
+ {
608
+ "page": "Deferred deep links in Expo, end to end",
609
+ "group": "Recipes",
610
+ "section": "Next steps",
611
+ "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."
613
+ },
614
+ {
615
+ "page": "Deferred deep links in Flutter, end to end",
616
+ "group": "Recipes",
617
+ "section": null,
618
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/",
619
+ "markdown": "This page strings the separate Flutter pages into one path for the case that matters most to growth teams: a new user. Each step links to the page with more detail. The same flow, in React Native: [React Native recipe](https://straitlink.in/docs/recipes/react-native-deferred-deep-links/)."
620
+ },
621
+ {
622
+ "page": "Deferred deep links in Flutter, end to end",
623
+ "group": "Recipes",
624
+ "section": "1. Make a link for one screen",
625
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#1-make-a-link-for-one-screen",
626
+ "markdown": "In **Dashboard → Links → New link**, set the slug `summer` and the destination `https://shop.example.com/product/42?ref=SUMMER`. Or from your server:\n\n```bash\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?ref=SUMMER\", \"campaign\": \"summer_2026\" }'\n```\n\nYour app receives the destination's path and query (`/product/42`, `{ref: SUMMER}`), so use paths your `GoRouter` already has.\n\n**You should see:** the link `https://hilltop.strait.link/summer` in the Links list."
627
+ },
628
+ {
629
+ "page": "Deferred deep links in Flutter, end to end",
630
+ "group": "Recipes",
631
+ "section": "2. Add the package and the Play Install Referrer",
632
+ "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)."
634
+ },
635
+ {
636
+ "page": "Deferred deep links in Flutter, end to end",
637
+ "group": "Recipes",
638
+ "section": "3. Route every link, including the deferred one",
639
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#3-route-every-link-including-the-deferred-one",
640
+ "markdown": "The deferred link arrives on the same `links.onLink` stream as any other link, once, on the first launch after the install. The stream replays events from before you listened, so listening after `startLinks()` never misses it:\n\n```dart title=\"lib/main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:strait_sdk/strait_sdk.dart';\nimport 'links.dart';\n\nfinal router = GoRouter(routes: [\n GoRoute(path: '/', builder: (_, __) => const HomeScreen()),\n GoRoute(path: '/product/:id', builder: (_, s) => ProductScreen(id: s.pathParameters['id']!)),\n]);\n\nFuture<void> main() async {\n await startLinks();\n links.onLink.listen((e) {\n if (e.kind == LinkKind.deferred) debugPrint('Strait first open: ${e.route.value} ${e.matched} ${e.reason}');\n if (e.matched && e.path != null) router.go(Uri(path: e.path, queryParameters: e.params).toString());\n });\n runApp(MaterialApp.router(routerConfig: router));\n}\n```\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens `ProductScreen` with `id` `42`. This proves the routing; the next steps prove the install."
641
+ },
642
+ {
643
+ "page": "Deferred deep links in Flutter, end to end",
644
+ "group": "Recipes",
645
+ "section": "4. Decide what a new user sees first",
646
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#4-decide-what-a-new-user-sees-first",
647
+ "markdown": "On the first launch the package always answers once with `LinkKind.deferred`. When `matched` is `false`, `reason` is `no_match` (most installs don't come from a link) or `network` (the check runs again on the next launch). Show your usual onboarding in both cases, and skip it when a link matched:\n\n```dart title=\"lib/first_open.dart\"\nimport 'package:strait_sdk/strait_sdk.dart';\nimport 'links.dart';\n\n/// The link's path for a new user who came from a link, or null.\n/// Later launches have no deferred event, so the timeout answers null.\nFuture<String?> firstOpenPath() => links.onLink\n .firstWhere((e) => e.kind == LinkKind.deferred)\n .then<String?>((e) => e.matched ? e.path : null)\n .timeout(const Duration(seconds: 5), onTimeout: () => null);\n```\n\nShow a short loading screen while you wait, for example with a `FutureBuilder`.\n\n**You should see:** a fresh debug install, with no link tapped, logs `Strait first open: fingerprint false no_match` and shows your onboarding."
648
+ },
649
+ {
650
+ "page": "Deferred deep links in Flutter, end to end",
651
+ "group": "Recipes",
652
+ "section": "5. Ship a Google Play internal-testing build",
653
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#5-ship-a-google-play-internal-testing-build",
654
+ "markdown": "A build from `flutter run` has no install referrer, so this test needs a build installed from Google Play:\n\n1. Run `flutter build appbundle`, upload the `.aab` to **internal testing** in Play Console and add yourself as a tester.\n2. In **Dashboard → Apps & stores → People without the app**, choose **Always Google Play** while the app isn't public. (The default sends people to your website while the Play listing isn't live.)\n\n**You should see:** the build listed as available to testers in Play Console. Google says the first review can take up to 7 days, or longer in exceptional cases ([Play Console Help](https://support.google.com/googleplay/android-developer/answer/9859751))."
655
+ },
656
+ {
657
+ "page": "Deferred deep links in Flutter, end to end",
658
+ "group": "Recipes",
659
+ "section": "6. Tap, install, open",
660
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#6-tap-install-open",
661
+ "markdown": "1. Uninstall the app from the phone.\n2. Send yourself `https://hilltop.strait.link/summer` in WhatsApp or Messages and tap it. Google Play opens on your app.\n3. Install from Play and open the app.\n\n**You should see:** the app opens directly on the Product screen for `42`, with `ref` `SUMMER` in its params, and `adb logcat | grep flutter` shows `Strait first open: install_referrer true`. The check runs once per install; uninstall to repeat it."
662
+ },
663
+ {
664
+ "page": "Deferred deep links in Flutter, end to end",
665
+ "group": "Recipes",
666
+ "section": "7. See the install in Analytics",
667
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#7-see-the-install-in-analytics",
668
+ "markdown": "Open **Dashboard → Analytics** and choose `summer_2026` in the **Campaign** filter. To put revenue on the same tap, record a purchase from the app with `await links.trackEvent('purchase', value: 499, currency: 'INR');` ([Events & revenue](https://straitlink.in/docs/sdks/flutter/events/)).\n\n**You should see:** your tap on `summer`, one install with match method `install_referrer` joined to that exact tap, and one app open from a new user. A test purchase appears under the same campaign."
669
+ },
670
+ {
671
+ "page": "Deferred deep links in Flutter, end to end",
672
+ "group": "Recipes",
673
+ "section": "Common errors",
674
+ "url": "https://straitlink.in/docs/recipes/flutter-deferred-deep-links/#common-errors",
675
+ "markdown": "- **The first open lands on the home screen.** The app came from `flutter run` or a shared APK, not Google Play, or the Apps & stores choice in step 5 still sends people to your website. [Test page](https://straitlink.in/docs/sdks/flutter/test/#4-test-a-deferred-link-from-google-play).\n- **The link opens a route like `/summer` that doesn't exist.** Flutter's own deep linking is still on, so it routed the raw URL. Set `flutter_deeplinking_enabled` to `false` (step 2).\n- **`MissingPluginException` on start.** You added the plugins and hot-restarted. Stop the app and run `flutter run` again."
676
+ },
677
+ {
678
+ "page": "Deferred deep links in Flutter, end to end",
679
+ "group": "Recipes",
680
+ "section": "Next steps",
681
+ "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."
683
+ },
684
+ {
685
+ "page": "Platforms and SDKs",
686
+ "group": "Platforms",
687
+ "section": "App SDKs",
688
+ "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**."
690
+ },
691
+ {
692
+ "page": "Platforms and SDKs",
693
+ "group": "Platforms",
694
+ "section": "Every SDK covers the same ground",
695
+ "url": "https://straitlink.in/docs/sdks/#every-sdk-covers-the-same-ground",
696
+ "markdown": "Whichever SDK you use, its docs answer the same five questions:\n\n| SDK | Set up | Deep links | Deferred deep links | Events | Common errors |\n|---|---|---|---|---|---|\n| React Native | [Install](https://straitlink.in/docs/sdks/react-native/) | [Receive links](https://straitlink.in/docs/sdks/react-native/receive-links/) | [End to end](https://straitlink.in/docs/recipes/react-native-deferred-deep-links/) | [Events](https://straitlink.in/docs/sdks/react-native/events/) | [Errors](https://straitlink.in/docs/sdks/react-native/#common-errors) |\n| Expo | [Install](https://straitlink.in/docs/sdks/expo/) | [Expo Router](https://straitlink.in/docs/sdks/expo/#3-route-links-with-expo-router) | [End to end](https://straitlink.in/docs/recipes/expo-deferred-deep-links/) | [Events](https://straitlink.in/docs/sdks/react-native/events/) | [Errors](https://straitlink.in/docs/sdks/expo/#common-errors) |\n| Flutter | [Install](https://straitlink.in/docs/sdks/flutter/) | [Receive links](https://straitlink.in/docs/sdks/flutter/receive-links/) | [End to end](https://straitlink.in/docs/recipes/flutter-deferred-deep-links/) | [Events](https://straitlink.in/docs/sdks/flutter/events/) | [Errors](https://straitlink.in/docs/sdks/flutter/#common-errors) |\n| Android | [Install](https://straitlink.in/docs/sdks/android/) | [Receive links](https://straitlink.in/docs/sdks/android/receive-links/) | [Install referrer](https://straitlink.in/docs/sdks/android/#2-read-the-play-install-referrer) | [Events](https://straitlink.in/docs/sdks/android/events/) | [Errors](https://straitlink.in/docs/sdks/android/#common-errors) |\n| iOS (Swift) | [Install](https://straitlink.in/docs/sdks/ios/#install) | [Deep links](https://straitlink.in/docs/sdks/ios/#handle-deep-links) | [Deferred](https://straitlink.in/docs/sdks/ios/#deferred-deep-links) | [Events](https://straitlink.in/docs/sdks/ios/#events) | [Errors](https://straitlink.in/docs/sdks/ios/#common-errors) |\n| Unity | [Install](https://straitlink.in/docs/sdks/unity/#install) | [Deep links](https://straitlink.in/docs/sdks/unity/#handle-deep-links) | [Deferred](https://straitlink.in/docs/sdks/unity/#deferred-deep-links) | [Events](https://straitlink.in/docs/sdks/unity/#events) | [Errors](https://straitlink.in/docs/sdks/unity/#common-errors) |\n| Web | [Install](https://straitlink.in/docs/sdks/web/#install) | [Deep links](https://straitlink.in/docs/sdks/web/#handle-deep-links) | [Deferred](https://straitlink.in/docs/sdks/web/#deferred-deep-links) | [Events](https://straitlink.in/docs/sdks/web/#events) | [Errors](https://straitlink.in/docs/sdks/web/#common-errors) |\n| Node (server) | [Install](https://straitlink.in/docs/sdks/node/#install) | [Links and opens](https://straitlink.in/docs/sdks/node/#handle-deep-links) | [Deferred](https://straitlink.in/docs/sdks/node/#deferred-deep-links) | [Events](https://straitlink.in/docs/sdks/node/#events) | [Errors](https://straitlink.in/docs/sdks/node/#common-errors) |\n\nAny `error` the API sends back is listed in the [Error reference](https://straitlink.in/docs/errors/), and the limits in [Rate limits](https://straitlink.in/docs/rate-limits/)."
697
+ },
698
+ {
699
+ "page": "Platforms and SDKs",
700
+ "group": "Platforms",
701
+ "section": "Set up an app with one command",
702
+ "url": "https://straitlink.in/docs/sdks/#set-up-an-app-with-one-command",
703
+ "markdown": "`strait init` (`@straitlink/cli`) recognises a React Native, Expo, Flutter or Android app, writes the integration file with your publishable key and link host, and prints the remaining steps for your app. It refuses a secret key and never edits your existing files.\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```"
704
+ },
705
+ {
706
+ "page": "Platforms and SDKs",
707
+ "group": "Platforms",
708
+ "section": "Web and server",
709
+ "url": "https://straitlink.in/docs/sdks/#web-and-server",
710
+ "markdown": "| Platform | Package | What it covers | Guide |\n|---|---|---|---|\n| Web | `@straitlink/web` | Deferred link matching for web apps and PWAs | [Web](https://straitlink.in/docs/sdks/web/) |\n| Node | `@straitlink/node` | Create and manage links, send events from your backend | [Node](https://straitlink.in/docs/sdks/node/) |"
711
+ },
712
+ {
713
+ "page": "Platforms and SDKs",
714
+ "group": "Platforms",
715
+ "section": "What's tested",
716
+ "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/)."
718
+ },
719
+ {
720
+ "page": "React Native deep linking",
721
+ "group": "Platforms",
722
+ "section": null,
723
+ "url": "https://straitlink.in/docs/sdks/react-native/",
724
+ "markdown": "Using Expo? Follow [Expo (Expo Router)](https://straitlink.in/docs/sdks/expo/) instead: the setup is the same, but the native settings go in `app.json`."
725
+ },
726
+ {
727
+ "page": "React Native deep linking",
728
+ "group": "Platforms",
729
+ "section": "1. Install the SDK",
730
+ "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."
732
+ },
733
+ {
734
+ "page": "React Native deep linking",
735
+ "group": "Platforms",
736
+ "section": "2. Create the client once",
737
+ "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."
739
+ },
740
+ {
741
+ "page": "React Native deep linking",
742
+ "group": "Platforms",
743
+ "section": "3. Let the phone hand links to your app",
744
+ "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```"
746
+ },
747
+ {
748
+ "page": "React Native deep linking",
749
+ "group": "Platforms",
750
+ "section": "4. Check that the SDK sees the link",
751
+ "url": "https://straitlink.in/docs/sdks/react-native/#4-check-that-the-sdk-sees-the-link",
752
+ "markdown": "Add a temporary log next to `start()`:\n\n```ts title=\"links.ts\"\nlinks.onLink((e) => console.log('Strait link:', e.kind, e.route, e.matched, e.path));\n```\n\nRun the `adb` command from step 3 again.\n\n**You should see:** Metro logs `Strait link: direct custom_scheme true /product/42`. Then tap a real link from Messages or Gmail: `route` becomes `custom_scheme` (browser hand-off) or `app_link` (verified link)."
753
+ },
754
+ {
755
+ "page": "React Native deep linking",
756
+ "group": "Platforms",
757
+ "section": "Or let `strait init` write the setup",
758
+ "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."
760
+ },
761
+ {
762
+ "page": "React Native deep linking",
763
+ "group": "Platforms",
764
+ "section": "Common errors",
765
+ "url": "https://straitlink.in/docs/sdks/react-native/#common-errors",
766
+ "markdown": "- **`Native module ... is null` or `PlayInstallReferrer` is undefined.** The app wasn't rebuilt after `npm install`. Native modules need a new build, not just a Metro reload.\n- **The `adb` command opens a chooser or another app.** Another installed app also claims the scheme. Pick a more specific one, like `hilltopshoes`.\n- **The callback never fires.** `links.ts` is not imported at startup, so `start()` never runs."
767
+ },
768
+ {
769
+ "page": "React Native deep linking",
770
+ "group": "Platforms",
771
+ "section": "Next steps",
772
+ "url": "https://straitlink.in/docs/sdks/react-native/#next-steps",
773
+ "markdown": "- [Receive links](https://straitlink.in/docs/sdks/react-native/receive-links/): send `e.path` to the right screen with React Navigation.\n- [Test](https://straitlink.in/docs/sdks/react-native/test/): deep links, deferred links and analytics on a real phone."
774
+ },
775
+ {
776
+ "page": "Receive links in React Native",
777
+ "group": "Platforms",
778
+ "section": null,
779
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/",
780
+ "markdown": "`e.path` is the destination's path (`/product/42`) and `e.params` its query parameters. One `onLink` callback covers every case. Expo Router has its own recipe on the [Expo page](https://straitlink.in/docs/sdks/expo/#3-route-links-with-expo-router)."
781
+ },
782
+ {
783
+ "page": "Receive links in React Native",
784
+ "group": "Platforms",
785
+ "section": "1. Map paths to screens",
786
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#1-map-paths-to-screens",
787
+ "markdown": "Keep the mapping in one place, using a navigation ref so it works outside components:\n\n```tsx title=\"navigation.ts\"\nimport { createNavigationContainerRef } from '@react-navigation/native';\n\nexport const navigationRef = createNavigationContainerRef<any>();\n\n// Map a link's path to one of your screens.\nexport function openPath(path: string, params: Record<string, string> = {}) {\n const product = /^\\/product\\/([^/]+)$/.exec(path);\n if (product) navigationRef.navigate('Product', { id: product[1], ...params });\n else navigationRef.navigate('Home');\n}\n```\n\n**You should see:** the app compiles. Nothing calls `openPath` yet."
788
+ },
789
+ {
790
+ "page": "Receive links in React Native",
791
+ "group": "Platforms",
792
+ "section": "2. Subscribe when the navigator is ready",
793
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#2-subscribe-when-the-navigator-is-ready",
794
+ "markdown": "```tsx title=\"App.tsx\"\nimport { NavigationContainer } from '@react-navigation/native';\nimport { links } from './links';\nimport { navigationRef, openPath } from './navigation';\n\nexport default function App() {\n return (\n <NavigationContainer\n ref={navigationRef}\n onReady={() => links.onLink((e) => e.matched && e.path && openPath(e.path, e.params))}\n >\n {/* your navigator */}\n </NavigationContainer>\n );\n}\n```\n\nDon't also list your link domain in React Navigation's `linking` option, or each link is handled twice.\n\n**You should see:** with the app closed, `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens the Product screen with `id` `42`. Run it again with the app in the background: same screen."
795
+ },
796
+ {
797
+ "page": "Receive links in React Native",
798
+ "group": "Platforms",
799
+ "section": "3. Show a loading state (optional)",
800
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#3-show-a-loading-state-optional",
801
+ "markdown": "A short link can take a second or more to resolve on a slow network. `onLinkStart` fires as soon as a link arrives:\n\n```tsx title=\"LinkLoading.tsx\"\nimport { useEffect, useState } from 'react';\nimport { ActivityIndicator, StyleSheet } from 'react-native';\nimport { links } from './links';\n\nexport function LinkLoading() {\n const [opening, setOpening] = useState(false);\n useEffect(() => {\n const offStart = links.onLinkStart(() => setOpening(true));\n const offLink = links.onLink(() => setOpening(false));\n return () => { offStart(); offLink(); };\n }, []);\n return opening ? <ActivityIndicator style={StyleSheet.absoluteFill} size=\"large\" /> : null;\n}\n```\n\nRender `<LinkLoading />` once, above your navigator.\n\n**You should see:** a spinner for a moment when you tap a `https://hilltop.strait.link/…` link on a slow connection, then the screen."
802
+ },
803
+ {
804
+ "page": "Receive links in React Native",
805
+ "group": "Platforms",
806
+ "section": "4. Handle links that don't match",
807
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#4-handle-links-that-dont-match",
808
+ "markdown": "When `matched` is `false`, `reason` says why: `not_found`, `expired`, `password_protected`, `no_match` (a first open with no recent tap) or `network`. The app has already opened, so show your home screen. A first open with no link at all is normal: most installs don't come from a link.\n\n**You should see:** tapping a link to a slug that doesn't exist opens the app on the home screen, and `e.reason` is `not_found`."
809
+ },
810
+ {
811
+ "page": "Receive links in React Native",
812
+ "group": "Platforms",
813
+ "section": "Common errors",
814
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#common-errors",
815
+ "markdown": "- **The screen opens twice.** Your link domain is also in React Navigation's `linking.prefixes`. Remove it.\n- **Nothing happens on a cold start.** `navigationRef.navigate` ran before the navigator was ready. Subscribe in `onReady`, as above.\n- **The path has your domain in it.** Use `e.path`, not `e.url`. `e.url` is the full destination."
816
+ },
817
+ {
818
+ "page": "Receive links in React Native",
819
+ "group": "Platforms",
820
+ "section": "Next steps",
821
+ "url": "https://straitlink.in/docs/sdks/react-native/receive-links/#next-steps",
822
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/react-native/events/): record purchases on the tap that brought them.\n- [Reference](https://straitlink.in/docs/sdks/react-native/reference/): every field of the link event and every method."
823
+ },
824
+ {
825
+ "page": "Create links from a React Native app",
826
+ "group": "Platforms",
827
+ "section": null,
828
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/",
829
+ "markdown": "Links are created with the **secret key**, which must never ship in an app. So the app asks your server, and your server asks Strait. The same pattern works for referral links: put the referrer in the destination's query, and it comes back in `e.params` when the link opens the app."
830
+ },
831
+ {
832
+ "page": "Create links from a React Native app",
833
+ "group": "Platforms",
834
+ "section": "1. Add an endpoint to your server",
835
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/#1-add-an-endpoint-to-your-server",
836
+ "markdown": "With the [Node SDK](https://straitlink.in/docs/sdks/node/) (any language can call `POST /v1/links` instead):\n\n```ts title=\"server/share.ts (Express)\"\nimport { randomBytes } from 'node:crypto';\nimport express from 'express';\nimport { StraitClient } from '@straitlink/node';\n\nconst strait = new StraitClient({ apiKey: process.env.SECRET_KEY!, baseUrl: 'https://strait.link' });\nconst app = express();\napp.use(express.json());\n\n// POST /share { \"productId\": \"42\" } → { \"url\": \"https://hilltop.strait.link/…\" }\napp.post('/share', async (req, res) => {\n const id = String(req.body.productId).replace(/[^\\w-]/g, '');\n const link = await strait.createLink({\n slug: `p${id}-${randomBytes(4).toString('hex')}`, // unique in your workspace\n longUrl: `https://shop.example.com/product/${id}?ref=${encodeURIComponent(req.user?.id ?? 'guest')}`,\n title: 'Look what I found', // preview in WhatsApp and other chat apps\n campaign: 'in_app_share',\n });\n res.json({ url: link.shortUrl });\n});\n```\n\nSlugs are unique within your workspace (letters, digits, `-` and `_`, up to 64), so add a random part. Check who's calling before you create links for them, as you would for any write.\n\n**You should see:** `curl -X POST localhost:3000/share -H 'Content-Type: application/json' -d '{\"productId\":\"42\"}'` returns `{\"url\":\"https://hilltop.strait.link/…\"}`."
837
+ },
838
+ {
839
+ "page": "Create links from a React Native app",
840
+ "group": "Platforms",
841
+ "section": "2. Call it from the app and share",
842
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/#2-call-it-from-the-app-and-share",
843
+ "markdown": "```tsx title=\"ShareButton.tsx\"\nimport { Button, Share } from 'react-native';\n\nexport function ShareButton({ productId }: { productId: string }) {\n async function share() {\n const res = await fetch('https://api.yourapp.com/share', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' }, // add your own auth header\n body: JSON.stringify({ productId }),\n });\n const { url } = await res.json();\n await Share.share({ message: url });\n }\n return <Button title=\"Share\" onPress={share} />;\n}\n```\n\n**You should see:** the system share sheet with a `https://hilltop.strait.link/…` link. Send it to yourself and tap it: the app opens on that product."
844
+ },
845
+ {
846
+ "page": "Create links from a React Native app",
847
+ "group": "Platforms",
848
+ "section": "3. Read the referrer when the link opens the app",
849
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/#3-read-the-referrer-when-the-link-opens-the-app",
850
+ "markdown": "The destination's query arrives in `e.params`, on a direct open and on the first open after an install:\n\n```ts title=\"links.ts\"\nlinks.onLink((e) => {\n if (e.matched && e.params?.ref) saveReferrer(e.params.ref); // your own code\n});\n```\n\n**You should see:** `e.params.ref` set to the sharer's id when a friend opens the shared link, including after installing from the store."
851
+ },
852
+ {
853
+ "page": "Create links from a React Native app",
854
+ "group": "Platforms",
855
+ "section": "Common errors",
856
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/#common-errors",
857
+ "markdown": "- **`401` from Strait.** Your server sent the publishable key. Link creation needs a secret key.\n- **`409`.** That `slug` is already used in your workspace. Make the random part longer.\n- **`402`.** The workspace reached its plan's link limit. Existing links keep working. See [Pricing](https://straitlink.in/pricing/)."
858
+ },
859
+ {
860
+ "page": "Create links from a React Native app",
861
+ "group": "Platforms",
862
+ "section": "Next steps",
863
+ "url": "https://straitlink.in/docs/sdks/react-native/create-links/#next-steps",
864
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/react-native/events/): see which shares led to purchases.\n- [Create a link (API)](https://straitlink.in/docs/api/create-link/): every field, including fallbacks, previews and expiry."
865
+ },
866
+ {
867
+ "page": "Events and revenue in React Native",
868
+ "group": "Platforms",
869
+ "section": "1. Know what's already reported",
870
+ "url": "https://straitlink.in/docs/sdks/react-native/events/#1-know-whats-already-reported",
871
+ "markdown": "Every time a link opens the app, the SDK reports it once. You don't write any analytics code for this.\n\n| How the app opened | Joined to |\n|---|---|\n| Verified link tapped in WhatsApp, Gmail, Messages… | The link (also counted as a tap) |\n| Browser handed off to the app (`yourapp://…`) | The exact tap |\n| First open after a Play install | The exact tap that sent the user to the store |\n| First open after an App Store install | The matched tap |\n| Your own https links | Host and path only, never the query |\n\nReports that can't be sent (offline, server busy) are saved and retried on the next start and whenever the app comes back to the foreground, for up to 7 days (100 at most). Each open has an id, so nothing is counted twice. Navigation never waits for a report.\n\n**You should see:** after tapping a link, **Dashboard → Analytics** shows the tap and the app open within a minute."
872
+ },
873
+ {
874
+ "page": "Events and revenue in React Native",
875
+ "group": "Platforms",
876
+ "section": "2. Record a purchase",
877
+ "url": "https://straitlink.in/docs/sdks/react-native/events/#2-record-a-purchase",
878
+ "markdown": "```ts\nimport { links } from './links';\n\nlinks.trackEvent('purchase', { value: 49.99, currency: 'USD' }); // the tap is attached automatically\n```\n\n`trackEvent` returns a promise that resolves to `true` when the event was accepted. Use any event name (`signup`, `add_to_cart`); `value` and `currency` are optional.\n\n**You should see:** the purchase at the bottom of the funnel in **Analytics**, with its revenue, on the channel of the link you tapped."
879
+ },
880
+ {
881
+ "page": "Events and revenue in React Native",
882
+ "group": "Platforms",
883
+ "section": "3. Understand which tap gets the credit",
884
+ "url": "https://straitlink.in/docs/sdks/react-native/events/#3-understand-which-tap-gets-the-credit",
885
+ "markdown": "The event carries the tap id of the most recent link open, as long as that open was in the last 7 days. The tap id comes from a browser hand-off to `yourapp://…?strait_click=…`, a Google Play install, an open from a verified short link, or an iPhone install match. When our server matches an open or an install, it sends the tap id back with its answer. That lets Analytics put the revenue on the exact tap's channel and A/B variant. Each newer open replaces the remembered tap with its own, so an older tap never takes credit for a newer open; if an open comes with no tap id, the remembered one is forgotten and the event carries none. To name the tap yourself, pass `clickId`, which overrides the remembered one. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\n**You should see:** two links tapped one after the other credit a purchase to the second one only."
886
+ },
887
+ {
888
+ "page": "Events and revenue in React Native",
889
+ "group": "Platforms",
890
+ "section": "Common errors",
891
+ "url": "https://straitlink.in/docs/sdks/react-native/events/#common-errors",
892
+ "markdown": "- **The purchase shows as direct.** The last link open was more than 7 days ago, or the app was opened without a link since. That's by design.\n- **`trackEvent` resolves to `false`.** The phone was offline or the key is wrong. Events aren't queued like open reports, so retry from your own code if it matters.\n- **Revenue is counted twice.** You also send the same purchase from your server with the [Node SDK](https://straitlink.in/docs/sdks/node/). Send it from one place."
893
+ },
894
+ {
895
+ "page": "Events and revenue in React Native",
896
+ "group": "Platforms",
897
+ "section": "Next steps",
898
+ "url": "https://straitlink.in/docs/sdks/react-native/events/#next-steps",
899
+ "markdown": "- [Analytics: how we count](https://straitlink.in/docs/analytics/): taps, opens, installs and revenue, defined.\n- [Webhooks](https://straitlink.in/docs/webhooks/): get each open and install on your server as it happens."
900
+ },
901
+ {
902
+ "page": "Test deep links in React Native",
903
+ "group": "Platforms",
904
+ "section": "1. Test the routing without a tap",
905
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#1-test-the-routing-without-a-tap",
906
+ "markdown": "`adb` hands your app a URL exactly as the phone would, so you can check routing before anything else:\n\n```bash\n# Browser hand-off (custom scheme)\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42?color=red\"\n\n# Verified link (needs App Links verified: go-live checklist, step 2)\nadb shell am start -W -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"\n```\n\n**You should see:** the Product screen for `42`, with `color` `red` in its params. Run each command once with the app closed and once with it in the background."
907
+ },
908
+ {
909
+ "page": "Test deep links in React Native",
910
+ "group": "Platforms",
911
+ "section": "2. Tap a real link from a chat app",
912
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#2-tap-a-real-link-from-a-chat-app",
913
+ "markdown": "Send `https://hilltop.strait.link/summer` to yourself in Google Messages, Gmail and WhatsApp, and tap it in each. Phones only open apps from links that are tapped, never from the address bar.\n\n**You should see:** the app opens on the link's screen. In WhatsApp or Instagram you may see an **Open in app** page first: in-app browsers often don't open apps on their own, so we show a page with an **Open in app** button and steps to open the link in the real browser."
914
+ },
915
+ {
916
+ "page": "Test deep links in React Native",
917
+ "group": "Platforms",
918
+ "section": "3. Ask what a tap would do, without tapping",
919
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#3-ask-what-a-tap-would-do-without-tapping",
920
+ "markdown": "`GET /v1/simulate` runs the same decision a real tap gets, for any device, without recording a tap:\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=whatsapp-android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\nPresets for `ua`: `android`, `iphone`, `ipad`, `desktop`, `instagram-ios`, `instagram-android`, `facebook-android`, `whatsapp-android`, `bot`. **Dashboard → Playground** runs the same check.\n\n**You should see:** a `decision`, the `location` the phone would be sent to, and a plain-English `reason`. See [Dry-run a tap](https://straitlink.in/docs/api/simulate-tap/)."
921
+ },
922
+ {
923
+ "page": "Test deep links in React Native",
924
+ "group": "Platforms",
925
+ "section": "4. Test a deferred link (first open after install)",
926
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#4-test-a-deferred-link-first-open-after-install",
927
+ "markdown": "On Android the link travels through the Google Play Install Referrer, so the app must be installed from Google Play. Internal testing is fine; a sideloaded build has no referrer. Set up internal testing once ([go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play)), then:\n\n1. Uninstall the app.\n2. Tap `https://hilltop.strait.link/summer` on the phone. Google Play opens.\n3. Install from Play and open the app.\n\n**You should see:** the app's first screen is the link's screen, and the event logged in step 4 of Install & set up has `kind: 'deferred'` and `route: 'install_referrer'`. The check runs once per install: uninstall again to repeat it."
928
+ },
929
+ {
930
+ "page": "Test deep links in React Native",
931
+ "group": "Platforms",
932
+ "section": "5. Check reports and re-run the deferred check",
933
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#5-check-reports-and-re-run-the-deferred-check",
934
+ "markdown": "```ts title=\"debug.ts\"\nimport { links } from './links';\n\nconsole.log('waiting to send:', await links.pendingOpenReports()); // opens saved while offline\nawait links.flushOpenReports(); // send them now\nconst e = await links.checkDeferred(); // re-run the deferred check\nconsole.log(e.kind, e.matched, e.reason, e.path);\n```\n\n`checkDeferred()` never records an install and doesn't touch the once-per-install flag, so it's safe to call while you debug.\n\n**You should see:** `waiting to send: 0` when the phone is online, and the same answer from `checkDeferred()` that the first launch got."
935
+ },
936
+ {
937
+ "page": "Test deep links in React Native",
938
+ "group": "Platforms",
939
+ "section": "Common errors",
940
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#common-errors",
941
+ "markdown": "- **Deferred test opens the home screen.** The app came from Android Studio, not Play, or \"When someone without the app taps a link\" isn't **Always Google Play** while the app is unlisted.\n- **Verified link opens the browser.** The installed build's certificate isn't in Apps & stores. [Go-live checklist, step 1](https://straitlink.in/docs/production-checklist/#1-add-every-sha-256-fingerprint).\n- **Works in Messages, not in Instagram.** Expected: Instagram's in-app browser often doesn't hand links to apps; the **Open in app** page is the way out. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
942
+ },
943
+ {
944
+ "page": "Test deep links in React Native",
945
+ "group": "Platforms",
946
+ "section": "Next steps",
947
+ "url": "https://straitlink.in/docs/sdks/react-native/test/#next-steps",
948
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): everything to finish before real users tap your links.\n- [Reference](https://straitlink.in/docs/sdks/react-native/reference/): every option, method and event field."
949
+ },
950
+ {
951
+ "page": "React Native SDK reference",
952
+ "group": "Platforms",
953
+ "section": "createStrait(config)",
954
+ "url": "https://straitlink.in/docs/sdks/react-native/reference/#createstraitconfig",
955
+ "markdown": "| Option | Type | Notes |\n|---|---|---|\n| `publishableKey` | `string` | **Required.** `st_pub_live_…`, from Dashboard → Get started |\n| `endpoint` | `string` | **Required.** Your link domain, `https://<handle>.strait.link` |\n| `storage` | `{ getItem, setItem }` | Pass `AsyncStorage`. Keeps the once-per-install flag and unsent open reports |\n| `installReferrer` | `() => Promise<string \\| null>` | `fromPlayInstallReferrer(PlayInstallReferrer)` for exact Android installs |\n| `linkHosts` | `string[]` | Extra hosts that serve your links. With one link domain you don't need it |\n| `clipboardBoost` | `boolean` | **Deprecated, ignored.** Paste handoff is switched on in the dashboard. See [the clipboard boost](#iphone-clipboard-boost-optional) |\n| `clipboard` | `{ hasProbableWebUrl, readText }` | Clipboard access for paste handoff: `fromExpoClipboard(Clipboard)`. Used only when your workspace has paste handoff on |"
956
+ },
957
+ {
958
+ "page": "React Native SDK reference",
959
+ "group": "Platforms",
960
+ "section": "Methods",
961
+ "url": "https://straitlink.in/docs/sdks/react-native/reference/#methods",
962
+ "markdown": "| Method | What it does |\n|---|---|\n| `start()` | Reads the launch link, listens for new ones and runs the deferred check once per install. Call once |\n| `onLink(cb)` | Every link event, including ones before you subscribed. Returns an unsubscribe function |\n| `onLinkStart(cb)` | A link just arrived and is being resolved (for a loading state). Returns an unsubscribe function |\n| `trackEvent(name, { value?, currency?, linkId?, clickId? })` | Records an event. Resolves `true` when accepted. See [Events & revenue](https://straitlink.in/docs/sdks/react-native/events/) |\n| `checkDeferred()` | Re-runs the deferred check (debugging). Never records an install |\n| `claimHandoff(text)` | Claims a clipboard-boost link your app got itself, for example from Apple's Paste button |\n| `pendingOpenReports()` | How many open reports are waiting to be sent |\n| `flushOpenReports()` | Sends them now (also happens on start and on resume) |\n| `stop()` | Stops listening for links and app-state changes |"
963
+ },
964
+ {
965
+ "page": "React Native SDK reference",
966
+ "group": "Platforms",
967
+ "section": "The link event",
968
+ "url": "https://straitlink.in/docs/sdks/react-native/reference/#the-link-event",
969
+ "markdown": "| Field | Meaning |\n|---|---|\n| `kind` | `direct` (a link opened the app) or `deferred` (tapped before install) |\n| `route` | `app_link` (verified link), `custom_scheme` (browser hand-off), `install_referrer` (Google Play), `fingerprint` (iPhone install match) or `clipboard` (iPhone clipboard boost) |\n| `appState` | `closed` (the link launched the app), `background` or `foreground` |\n| `matched` | `true` when the link resolved to a destination |\n| `reason` | Why `matched` is false: `not_found`, `expired`, `password_protected`, `no_match`, `network` or `invalid_url` |\n| `url`, `path`, `params` | The destination: full URL, its path (`/product/42`) and its query parameters |\n| `rawUrl` | The URL the phone handed the app |\n| `linkId` | The id of the Strait link |\n| `id` | This open's id (not the link's). The same id is on the matching `onLinkStart` signal |\n| `ms` | How long resolving took, in milliseconds |\n| `at` | When the link arrived (epoch milliseconds) |"
970
+ },
971
+ {
972
+ "page": "React Native SDK reference",
973
+ "group": "Platforms",
974
+ "section": "Deferred match only",
975
+ "url": "https://straitlink.in/docs/sdks/react-native/reference/#deferred-match-only",
976
+ "markdown": "If you only want the first-open-after-install destination, there's a one-call API. It never throws; on any error it returns `{ matched: false, matchMethod: 'none' }`.\n\n```ts\nimport { resolveDeferredLink, splitUrl } from '@straitlink/react-native';\nimport { navigationRef, openPath } from './navigation';\n\nconst result = await resolveDeferredLink({\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://hilltop.strait.link',\n});\nconst dest = result.matched && result.longUrl ? splitUrl(result.longUrl) : null;\nif (dest && navigationRef.isReady()) openPath(dest.path, dest.params);\n```"
977
+ },
978
+ {
979
+ "page": "React Native SDK reference",
980
+ "group": "Platforms",
981
+ "section": "How installs are matched",
982
+ "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."
984
+ },
985
+ {
986
+ "page": "React Native SDK reference",
987
+ "group": "Platforms",
988
+ "section": "iPhone clipboard boost (optional)",
989
+ "url": "https://straitlink.in/docs/sdks/react-native/reference/#iphone-clipboard-boost-optional",
990
+ "markdown": "Also called paste handoff. You switch it on or off in **Dashboard → Settings → iPhone installs → Paste handoff**, with no app release: the SDK reads the setting from `/v1/match` on the first launch and never keeps it. Pass a clipboard adapter once so the SDK can read the clipboard when your workspace asks for it. Without an adapter, or with paste handoff off, the SDK never touches the clipboard. The old `clipboardBoost: true` option is deprecated and ignored.\n\n```ts\nimport * as Clipboard from 'expo-clipboard';\nimport { createStrait, fromExpoClipboard } from '@straitlink/react-native';\n\nexport const links = createStrait({\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://hilltop.strait.link',\n clipboard: fromExpoClipboard(Clipboard), // used only when paste handoff is on\n});\n```\n\nOn the first launch only, and only when device matching found nothing and paste handoff is on, the SDK asks whether the clipboard probably holds a web link (iOS answers without a prompt). Only if it does, it reads the text, and iOS shows its **Allow Paste** prompt. Anything that isn't a Strait handoff link never leaves the phone."
991
+ },
992
+ {
993
+ "page": "Expo deep linking (Expo Router)",
994
+ "group": "Platforms",
995
+ "section": null,
996
+ "url": "https://straitlink.in/docs/sdks/expo/",
997
+ "markdown": "The SDK is the same one bare React Native uses. Two things differ: link settings go in `app.json`, and you need a development build, not Expo Go. For the whole path from tap to Google Play install, follow the [Expo deferred links recipe](https://straitlink.in/docs/recipes/expo-deferred-deep-links/)."
998
+ },
999
+ {
1000
+ "page": "Expo deep linking (Expo Router)",
1001
+ "group": "Platforms",
1002
+ "section": "1. Install the SDK",
1003
+ "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`."
1005
+ },
1006
+ {
1007
+ "page": "Expo deep linking (Expo Router)",
1008
+ "group": "Platforms",
1009
+ "section": "2. Add link settings to app.json",
1010
+ "url": "https://straitlink.in/docs/sdks/expo/#2-add-link-settings-to-appjson",
1011
+ "markdown": "Replace `hilltop.strait.link` with your own link domain, and `yourapp` with the custom scheme in Dashboard → Apps & stores:\n\n```json title=\"app.json\"\n{\n \"expo\": {\n \"scheme\": \"yourapp\",\n \"android\": {\n \"package\": \"com.yourcompany.yourapp\",\n \"intentFilters\": [\n {\n \"action\": \"VIEW\",\n \"autoVerify\": true,\n \"data\": [{ \"scheme\": \"https\", \"host\": \"hilltop.strait.link\" }],\n \"category\": [\"BROWSABLE\", \"DEFAULT\"]\n }\n ]\n },\n \"ios\": {\n \"bundleIdentifier\": \"com.yourcompany.yourapp\",\n \"associatedDomains\": [\"applinks:hilltop.strait.link\"]\n }\n }\n}\n```\n\n`scheme` registers `yourapp://` on both platforms (used when a browser hands a link to your app). The intent filter with `autoVerify` and `associatedDomains` let `https://hilltop.strait.link/…` links open the app directly once your fingerprints and Team ID are in Apps & stores.\n\n**You should see:** `npx expo config --type public` prints your `scheme`, `intentFilters` and `associatedDomains`."
1012
+ },
1013
+ {
1014
+ "page": "Expo deep linking (Expo Router)",
1015
+ "group": "Platforms",
1016
+ "section": "3. Route links with Expo Router",
1017
+ "url": "https://straitlink.in/docs/sdks/expo/#3-route-links-with-expo-router",
1018
+ "markdown": "Create the client (the same `links.ts` as [bare React Native](https://straitlink.in/docs/sdks/react-native/#2-create-the-client-once)), then subscribe in your root layout once navigation is ready:\n\n```tsx title=\"app/_layout.tsx\"\nimport { Stack, router, useRootNavigationState, type Href } from 'expo-router';\nimport { useEffect } from 'react';\nimport { links } from '../links';\n\nexport default function RootLayout() {\n const ready = useRootNavigationState()?.key;\n useEffect(() => {\n if (!ready) return;\n // Opened from a link, or first open after install: same callback.\n return links.onLink((e) => {\n if (e.matched && e.path) router.push({ pathname: e.path, params: e.params } as Href);\n });\n }, [ready]);\n return <Stack />;\n}\n```\n\nExpo Router also tries to open every incoming link itself, and `https://hilltop.strait.link/summer` isn't a screen in your app. This file sends it to your first screen and lets `onLink` do the routing:\n\n```tsx title=\"app/+native-intent.tsx\"\n// Links reach the app through Strait's onLink (app/_layout.tsx), which knows the\n// real destination. Keep Expo Router from routing the raw link URL.\nexport function redirectSystemPath({ path }: { path: string; initial: boolean }) { return '/'; }\n```\n\nIf your app has other deep links of its own (a sign-in callback, for example), return `path` for those instead of `'/'`.\n\n**You should see:** no \"Unmatched route\" screen when a link opens the app."
1019
+ },
1020
+ {
1021
+ "page": "Expo deep linking (Expo Router)",
1022
+ "group": "Platforms",
1023
+ "section": "4. Make a development build",
1024
+ "url": "https://straitlink.in/docs/sdks/expo/#4-make-a-development-build",
1025
+ "markdown": "Expo Go can't load the native module, and links on your domain open your app, not Expo Go. Build your own app:\n\n```bash\nnpx expo run:android # or npx expo run:ios; runs prebuild for you\neas build --profile development --platform android # or build in the cloud with EAS Build\n```\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens your `app/product/[id].tsx` screen with `id` `42`."
1026
+ },
1027
+ {
1028
+ "page": "Expo deep linking (Expo Router)",
1029
+ "group": "Platforms",
1030
+ "section": "5. Add your SHA-256 fingerprints",
1031
+ "url": "https://straitlink.in/docs/sdks/expo/#5-add-your-sha-256-fingerprints",
1032
+ "markdown": "For verified links, Dashboard → Apps & stores needs the certificate of each build you install:\n\n```bash\n# Local builds (npx expo run:android): the project's debug key\nkeytool -list -v -keystore android/app/debug.keystore -storepass android | grep SHA256\n\n# EAS builds: choose Android and your build profile; the keystore's SHA-256 is listed\neas credentials\n```\n\nFor builds installed from Google Play, also add the **Play App Signing** key from Play Console → Test and release → App integrity.\n\n**You should see:** `adb shell pm get-app-links com.yourcompany.yourapp` reports `hilltop.strait.link: verified` after a reinstall."
1033
+ },
1034
+ {
1035
+ "page": "Expo deep linking (Expo Router)",
1036
+ "group": "Platforms",
1037
+ "section": "Or let `strait init` write the setup",
1038
+ "url": "https://straitlink.in/docs/sdks/expo/#or-let-strait-init-write-the-setup",
1039
+ "markdown": "From your app's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt reads your `app.json`, writes `links.ts` and, with Expo Router, `app/+native-intent.tsx` (step 3), and prints the install commands, the `app.json` block for step 2 and the root layout code, filled in with your package, scheme and host. It never edits your existing files."
1040
+ },
1041
+ {
1042
+ "page": "Expo deep linking (Expo Router)",
1043
+ "group": "Platforms",
1044
+ "section": "Common errors",
1045
+ "url": "https://straitlink.in/docs/sdks/expo/#common-errors",
1046
+ "markdown": "- **Links open Expo Go or nothing at all.** You're running in Expo Go. Use the development build from step 4.\n- **\"Unmatched route\" after a tap.** `app/+native-intent.tsx` is missing or returns `path` for your link domain.\n- **Works locally, not from EAS.** The EAS keystore's SHA-256 isn't in Settings (step 5)."
1047
+ },
1048
+ {
1049
+ "page": "Expo deep linking (Expo Router)",
1050
+ "group": "Platforms",
1051
+ "section": "Next steps",
1052
+ "url": "https://straitlink.in/docs/sdks/expo/#next-steps",
1053
+ "markdown": "- [Test](https://straitlink.in/docs/sdks/react-native/test/): deep links, deferred links from Google Play, and analytics.\n- [Events & revenue](https://straitlink.in/docs/sdks/react-native/events/): put purchases on the tap that brought them."
1054
+ },
1055
+ {
1056
+ "page": "Flutter deep linking",
1057
+ "group": "Platforms",
1058
+ "section": null,
1059
+ "url": "https://straitlink.in/docs/sdks/flutter/",
1060
+ "markdown": "`strait_sdk` is pure Dart. Your app hands it the launch link, a stream of later links and the app lifecycle; it resolves short links, runs the deferred check once per install and emits one event per link. For the whole path from tap to Google Play install, follow the [Flutter deferred links recipe](https://straitlink.in/docs/recipes/flutter-deferred-deep-links/)."
1061
+ },
1062
+ {
1063
+ "page": "Flutter deep linking",
1064
+ "group": "Platforms",
1065
+ "section": "1. Install the package",
1066
+ "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."
1068
+ },
1069
+ {
1070
+ "page": "Flutter deep linking",
1071
+ "group": "Platforms",
1072
+ "section": "2. Create the client once",
1073
+ "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."
1075
+ },
1076
+ {
1077
+ "page": "Flutter deep linking",
1078
+ "group": "Platforms",
1079
+ "section": "3. Let the phone hand links to your app",
1080
+ "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```"
1082
+ },
1083
+ {
1084
+ "page": "Flutter deep linking",
1085
+ "group": "Platforms",
1086
+ "section": "4. Check that the SDK sees the link",
1087
+ "url": "https://straitlink.in/docs/sdks/flutter/#4-check-that-the-sdk-sees-the-link",
1088
+ "markdown": "Run the `adb` command again with the app open.\n\n**You should see:** `Strait link: direct custom_scheme true /product/42` in the `flutter run` console."
1089
+ },
1090
+ {
1091
+ "page": "Flutter deep linking",
1092
+ "group": "Platforms",
1093
+ "section": "Or let `strait init` write the setup",
1094
+ "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."
1096
+ },
1097
+ {
1098
+ "page": "Flutter deep linking",
1099
+ "group": "Platforms",
1100
+ "section": "Common errors",
1101
+ "url": "https://straitlink.in/docs/sdks/flutter/#common-errors",
1102
+ "markdown": "- **`MissingPluginException`.** You added a plugin and hot-restarted. Stop and `flutter run` again.\n- **The link opens a Flutter route like `/summer` that doesn't exist.** Flutter's deep linking is still on (step 3).\n- **iPhone installs never match.** The time zone isn't an IANA name (step 2)."
1103
+ },
1104
+ {
1105
+ "page": "Flutter deep linking",
1106
+ "group": "Platforms",
1107
+ "section": "Next steps",
1108
+ "url": "https://straitlink.in/docs/sdks/flutter/#next-steps",
1109
+ "markdown": "- [Receive links](https://straitlink.in/docs/sdks/flutter/receive-links/): open the right screen with `go_router` or a navigator key.\n- [Test](https://straitlink.in/docs/sdks/flutter/test/): deep links, deferred links from Google Play, and analytics."
1110
+ },
1111
+ {
1112
+ "page": "Receive links in Flutter",
1113
+ "group": "Platforms",
1114
+ "section": null,
1115
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/",
1116
+ "markdown": "`e.path` is the destination's path (`/product/42`) and `e.params` its query parameters. `links.onLink` replays past events to late subscribers, so you can subscribe after the first frame without missing the launch link."
1117
+ },
1118
+ {
1119
+ "page": "Receive links in Flutter",
1120
+ "group": "Platforms",
1121
+ "section": "1. Route with go_router",
1122
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#1-route-with-gorouter",
1123
+ "markdown": "If your app uses [`go_router`](https://pub.dev/packages/go_router), subscribe once the router exists:\n\n```dart title=\"main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'links.dart';\n\nfinal router = GoRouter(routes: [\n GoRoute(path: '/', builder: (_, __) => const HomeScreen()),\n GoRoute(path: '/product/:id', builder: (_, s) => ProductScreen(id: s.pathParameters['id']!)),\n]);\n\nFuture<void> main() async {\n await startLinks();\n links.onLink.listen((e) {\n if (e.matched && e.path != null) {\n router.go(Uri(path: e.path, queryParameters: e.params).toString());\n }\n });\n runApp(MaterialApp.router(routerConfig: router));\n}\n```\n\n**You should see:** with the app closed, `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens `ProductScreen` with `id` `42`."
1124
+ },
1125
+ {
1126
+ "page": "Receive links in Flutter",
1127
+ "group": "Platforms",
1128
+ "section": "2. Or route with a navigator key",
1129
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#2-or-route-with-a-navigator-key",
1130
+ "markdown": "Without a router package, use a global key so the listener can navigate from anywhere:\n\n```dart title=\"main.dart\"\nfinal navigatorKey = GlobalKey<NavigatorState>();\n\nvoid openPath(String path, Map<String, String> params) {\n final product = RegExp(r'^/product/([^/]+)$').firstMatch(path);\n if (product != null) {\n navigatorKey.currentState?.push(MaterialPageRoute(builder: (_) => ProductScreen(id: product.group(1)!)));\n }\n}\n\nFuture<void> main() async {\n await startLinks();\n runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomeScreen()));\n links.onLink.listen((e) { if (e.matched && e.path != null) openPath(e.path!, e.params ?? {}); });\n}\n```\n\n**You should see:** the same Product screen, with the app closed and with it in the background."
1131
+ },
1132
+ {
1133
+ "page": "Receive links in Flutter",
1134
+ "group": "Platforms",
1135
+ "section": "3. Show a loading state (optional)",
1136
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#3-show-a-loading-state-optional",
1137
+ "markdown": "`links.onLinkStart` fires as soon as a link arrives, before a short link is resolved:\n\n```dart\nlinks.onLinkStart.listen((_) => showOpening.value = true); // a ValueNotifier<bool> your UI watches\nlinks.onLink.listen((_) => showOpening.value = false);\n```\n\n**You should see:** your spinner for a moment on a slow connection, then the screen."
1138
+ },
1139
+ {
1140
+ "page": "Receive links in Flutter",
1141
+ "group": "Platforms",
1142
+ "section": "4. Handle links that don't match",
1143
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#4-handle-links-that-dont-match",
1144
+ "markdown": "When `matched` is false, `reason` says why: `not_found`, `expired`, `password_protected`, `no_match`, `network` or `invalid_url`. The app is already open, so stay on the home screen.\n\n**You should see:** a link to a slug that doesn't exist opens the home screen with `e.reason == 'not_found'`."
1145
+ },
1146
+ {
1147
+ "page": "Receive links in Flutter",
1148
+ "group": "Platforms",
1149
+ "section": "Common errors",
1150
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#common-errors",
1151
+ "markdown": "- **The screen opens twice.** Flutter's built-in deep linking is still on. Set `flutter_deeplinking_enabled` to `false` (Install & set up, step 3).\n- **Nothing happens on a cold start.** Navigation ran before `runApp`. Subscribe after `runApp`, or use `go_router`, which queues the location.\n- **The path has your domain in it.** Use `e.path`, not `e.url`."
1152
+ },
1153
+ {
1154
+ "page": "Receive links in Flutter",
1155
+ "group": "Platforms",
1156
+ "section": "Next steps",
1157
+ "url": "https://straitlink.in/docs/sdks/flutter/receive-links/#next-steps",
1158
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/flutter/events/): record purchases on the tap that brought them.\n- [Reference](https://straitlink.in/docs/sdks/flutter/reference/): every field of the link event."
1159
+ },
1160
+ {
1161
+ "page": "Create links from a Flutter app",
1162
+ "group": "Platforms",
1163
+ "section": null,
1164
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/",
1165
+ "markdown": "Links are created with the **secret key**, which must never ship in an app. The app asks your server; your server calls `POST /v1/links`."
1166
+ },
1167
+ {
1168
+ "page": "Create links from a Flutter app",
1169
+ "group": "Platforms",
1170
+ "section": "1. Add an endpoint to your server",
1171
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/#1-add-an-endpoint-to-your-server",
1172
+ "markdown": "Use the server code from the [React Native page](https://straitlink.in/docs/sdks/react-native/create-links/#1-add-an-endpoint-to-your-server) (Node), or call the API from any language:\n\n```bash\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"slug\": \"p42-7f3e2c1a\", \"longUrl\": \"https://shop.example.com/product/42?ref=user_123\", \"campaign\": \"in_app_share\" }'\n```\n\nSlugs are unique within your workspace, so your server should add a random part. Reply to the app with the `shortUrl` from the answer.\n\n**You should see:** a `201` answer with `\"shortUrl\": \"https://hilltop.strait.link/p42-7f3e2c1a\"`."
1173
+ },
1174
+ {
1175
+ "page": "Create links from a Flutter app",
1176
+ "group": "Platforms",
1177
+ "section": "2. Call it from the app and share",
1178
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/#2-call-it-from-the-app-and-share",
1179
+ "markdown": "```bash\nflutter pub add http share_plus\n```\n\n```dart title=\"share_button.dart\"\nimport 'dart:convert';\nimport 'package:flutter/material.dart';\nimport 'package:http/http.dart' as http;\nimport 'package:share_plus/share_plus.dart';\n\nclass ShareButton extends StatelessWidget {\n const ShareButton({super.key, required this.productId});\n final String productId;\n\n Future<void> _share() async {\n final res = await http.post(\n Uri.parse('https://api.yourapp.com/share'), // your server, with your own auth\n headers: {'Content-Type': 'application/json'},\n body: jsonEncode({'productId': productId}),\n );\n final url = (jsonDecode(res.body) as Map<String, dynamic>)['url'] as String;\n await Share.share(url);\n }\n\n @override\n Widget build(BuildContext context) => TextButton(onPressed: _share, child: const Text('Share'));\n}\n```\n\n**You should see:** the share sheet with a `https://hilltop.strait.link/…` link. Tap it on another phone: the app opens on that product."
1180
+ },
1181
+ {
1182
+ "page": "Create links from a Flutter app",
1183
+ "group": "Platforms",
1184
+ "section": "3. Read the referrer when the link opens the app",
1185
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/#3-read-the-referrer-when-the-link-opens-the-app",
1186
+ "markdown": "```dart\nlinks.onLink.listen((e) {\n final ref = e.params?['ref'];\n if (e.matched && ref != null) saveReferrer(ref); // your own code\n});\n```\n\n**You should see:** `ref` set to the sharer's id when a friend opens the link, including after installing from the store."
1187
+ },
1188
+ {
1189
+ "page": "Create links from a Flutter app",
1190
+ "group": "Platforms",
1191
+ "section": "Common errors",
1192
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/#common-errors",
1193
+ "markdown": "- **`401` from Strait.** Your server sent the publishable key. Creating links needs a secret key.\n- **`409`.** The slug is already used in your workspace. Make the random part longer.\n- **`402`.** The workspace reached its plan's link limit. Existing links keep working."
1194
+ },
1195
+ {
1196
+ "page": "Create links from a Flutter app",
1197
+ "group": "Platforms",
1198
+ "section": "Next steps",
1199
+ "url": "https://straitlink.in/docs/sdks/flutter/create-links/#next-steps",
1200
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/flutter/events/): see which shares led to purchases.\n- [Create a link (API)](https://straitlink.in/docs/api/create-link/): every field, including fallbacks, previews and expiry."
1201
+ },
1202
+ {
1203
+ "page": "Events and revenue in Flutter",
1204
+ "group": "Platforms",
1205
+ "section": "1. Know what's already reported",
1206
+ "url": "https://straitlink.in/docs/sdks/flutter/events/#1-know-whats-already-reported",
1207
+ "markdown": "Every link open is reported once, automatically: verified links from chat apps, browser hand-offs, the first open after a Play install, iPhone install matches, and your own https links (host and path only). Opens that can't be sent are saved and retried for up to 7 days.\n\n**You should see:** after tapping a link, **Dashboard → Analytics** shows the tap and the app open within a minute."
1208
+ },
1209
+ {
1210
+ "page": "Events and revenue in Flutter",
1211
+ "group": "Platforms",
1212
+ "section": "2. Record a purchase",
1213
+ "url": "https://straitlink.in/docs/sdks/flutter/events/#2-record-a-purchase",
1214
+ "markdown": "```dart\nawait links.trackEvent('purchase', value: 49.99, currency: 'USD');\n```\n\n`trackEvent` returns `true` when the event was accepted. Use any event name; `value`, `currency`, `linkId` and `clickId` are optional.\n\n**You should see:** the purchase and its revenue at the bottom of the funnel in **Analytics**."
1215
+ },
1216
+ {
1217
+ "page": "Events and revenue in Flutter",
1218
+ "group": "Platforms",
1219
+ "section": "3. Understand which tap gets the credit",
1220
+ "url": "https://straitlink.in/docs/sdks/flutter/events/#3-understand-which-tap-gets-the-credit",
1221
+ "markdown": "The event carries the tap id of the most recent link open, as long as that open was in the last 7 days. The tap id comes from a browser hand-off to `yourapp://…?strait_click=…`, a Google Play install, an open from a verified short link, or an iPhone install match. When our server matches an open or an install, it sends the tap id back with its answer. That lets Analytics put the revenue on the exact tap's channel and A/B variant. Each newer open replaces the remembered tap with its own, so an older tap never takes credit for a newer open; if an open comes with no tap id, the remembered one is forgotten and the event carries none. To name the tap yourself, pass `clickId:`, which overrides the remembered one. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\n**You should see:** two links tapped one after the other credit a purchase to the second one only."
1222
+ },
1223
+ {
1224
+ "page": "Events and revenue in Flutter",
1225
+ "group": "Platforms",
1226
+ "section": "Common errors",
1227
+ "url": "https://straitlink.in/docs/sdks/flutter/events/#common-errors",
1228
+ "markdown": "- **The purchase shows as direct.** The last link open was more than 7 days ago, or the app was opened without a link since.\n- **`trackEvent` returns `false`.** The phone was offline or the key is wrong. Events aren't queued, so retry from your code if it matters.\n- **Revenue is counted twice.** The same purchase is also sent from your server. Send it from one place."
1229
+ },
1230
+ {
1231
+ "page": "Events and revenue in Flutter",
1232
+ "group": "Platforms",
1233
+ "section": "Next steps",
1234
+ "url": "https://straitlink.in/docs/sdks/flutter/events/#next-steps",
1235
+ "markdown": "- [Analytics: how we count](https://straitlink.in/docs/analytics/): taps, opens, installs and revenue, defined.\n- [Webhooks](https://straitlink.in/docs/webhooks/): get each open and install on your server as it happens."
1236
+ },
1237
+ {
1238
+ "page": "Test deep links in Flutter",
1239
+ "group": "Platforms",
1240
+ "section": "1. Test the routing without a tap",
1241
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#1-test-the-routing-without-a-tap",
1242
+ "markdown": "```bash\n# Browser hand-off (custom scheme)\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42?color=red\"\n\n# Verified link (needs App Links verified: go-live checklist, step 2)\nadb shell am start -W -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"\n```\n\n**You should see:** the Product screen for `42` with `color` `red`, once with the app closed and once from the background."
1243
+ },
1244
+ {
1245
+ "page": "Test deep links in Flutter",
1246
+ "group": "Platforms",
1247
+ "section": "2. Tap a real link from a chat app",
1248
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#2-tap-a-real-link-from-a-chat-app",
1249
+ "markdown": "Send `https://hilltop.strait.link/summer` to yourself in Google Messages, Gmail and WhatsApp, and tap it in each. Pasting into the address bar never opens an app.\n\n**You should see:** the app opens on the link's screen. WhatsApp and Instagram may show an **Open in app** page first, because in-app browsers often don't open apps on their own."
1250
+ },
1251
+ {
1252
+ "page": "Test deep links in Flutter",
1253
+ "group": "Platforms",
1254
+ "section": "3. Ask what a tap would do",
1255
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#3-ask-what-a-tap-would-do",
1256
+ "markdown": "```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=instagram-android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** a `decision`, the `location` the phone would go to, and a plain-English `reason`. No tap is recorded. See [Dry-run a tap](https://straitlink.in/docs/api/simulate-tap/)."
1257
+ },
1258
+ {
1259
+ "page": "Test deep links in Flutter",
1260
+ "group": "Platforms",
1261
+ "section": "4. Test a deferred link from Google Play",
1262
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#4-test-a-deferred-link-from-google-play",
1263
+ "markdown": "The link travels through the Google Play Install Referrer, so install from Google Play internal testing ([go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play)). Then uninstall, tap the link, install from Play and open the app.\n\n**You should see:** the first screen is the link's screen, and the console logs `Strait link: deferred install_referrer true /product/42`. Uninstall again to repeat: the check runs once per install."
1264
+ },
1265
+ {
1266
+ "page": "Test deep links in Flutter",
1267
+ "group": "Platforms",
1268
+ "section": "5. Check reports and re-run the deferred check",
1269
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#5-check-reports-and-re-run-the-deferred-check",
1270
+ "markdown": "```dart\ndebugPrint('waiting to send: ${await links.pendingOpenReports()}');\nawait links.flushOpenReports();\nfinal e = await links.checkDeferred(); // never records an install\ndebugPrint('${e.kind.name} ${e.matched} ${e.reason} ${e.path}');\n```\n\n**You should see:** `waiting to send: 0` when the phone is online."
1271
+ },
1272
+ {
1273
+ "page": "Test deep links in Flutter",
1274
+ "group": "Platforms",
1275
+ "section": "Common errors",
1276
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#common-errors",
1277
+ "markdown": "- **The deferred test opens the home screen.** The build came from `flutter run`, not Play, or \"When someone without the app taps a link\" isn't **Always Google Play** while the app is unlisted.\n- **Verified link opens the browser.** The installed build's certificate isn't in Apps & stores ([go-live checklist, step 1](https://straitlink.in/docs/production-checklist/#1-add-every-sha-256-fingerprint)).\n- **iPhone installs don't match.** Check the time zone is an IANA name. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
1278
+ },
1279
+ {
1280
+ "page": "Test deep links in Flutter",
1281
+ "group": "Platforms",
1282
+ "section": "Next steps",
1283
+ "url": "https://straitlink.in/docs/sdks/flutter/test/#next-steps",
1284
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): everything to finish before real users tap your links.\n- [Reference](https://straitlink.in/docs/sdks/flutter/reference/): every option, method and event field."
1285
+ },
1286
+ {
1287
+ "page": "Flutter SDK reference",
1288
+ "group": "Platforms",
1289
+ "section": "StraitLinks(...)",
1290
+ "url": "https://straitlink.in/docs/sdks/flutter/reference/#straitlinks",
1291
+ "markdown": "| Parameter | Type | Notes |\n|---|---|---|\n| `publishableKey` | `String` | **Required.** `st_pub_live_…` |\n| `endpoint` | `String` | **Required.** Your link domain, `https://<handle>.strait.link` |\n| `platform` | `String` | **Required.** `'ios'`, `'android'` or `'other'` |\n| `deviceFields` | `DeviceFields Function()` | **Required.** Screen width, pixel ratio, language, IANA time zone (see Install & set up) |\n| `storage` | `KeyValueStore` | Persists the once-per-install flag and unsent reports. Defaults to memory only |\n| `installReferrer` | `Future<String?> Function()` | Android: the Play Install Referrer string |\n| `linkHosts` | `List<String>` | Extra hosts that serve your links |\n| `clipboardBoost` | `bool` | **Deprecated, ignored.** Paste handoff is switched on in the dashboard and read from the server on first launch. See [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/) |\n| `clipboard` | `StraitClipboard` | `hasProbableWebUrl()` and `readText()`, used only when your workspace has paste handoff on |"
1292
+ },
1293
+ {
1294
+ "page": "Flutter SDK reference",
1295
+ "group": "Platforms",
1296
+ "section": "Methods and streams",
1297
+ "url": "https://straitlink.in/docs/sdks/flutter/reference/#methods-and-streams",
1298
+ "markdown": "| Member | What it does |\n|---|---|\n| `start({initialUrl, urls, lifecycle})` | Handles the launch link, listens to later ones and the lifecycle, runs the deferred check once |\n| `onLink` | `Stream<LinkEvent>`. Replays past events to late listeners |\n| `onLinkStart` | `Stream<LinkStart>`: a link arrived and is being resolved |\n| `handleUrl(url)` | Hands the SDK a URL yourself |\n| `trackEvent(name, {value, currency, linkId, clickId})` | Records an event; `true` when accepted |\n| `checkDeferred()` | Re-runs the deferred check (debugging). Never records an install |\n| `claimHandoff(text)` | Claims a clipboard-boost link your app got itself |\n| `pendingOpenReports()` / `flushOpenReports()` | Count, or send now, the open reports saved while offline |\n| `stop()` / `dispose()` | Stop listening; `dispose` also closes the streams |"
1299
+ },
1300
+ {
1301
+ "page": "Flutter SDK reference",
1302
+ "group": "Platforms",
1303
+ "section": "The link event",
1304
+ "url": "https://straitlink.in/docs/sdks/flutter/reference/#the-link-event",
1305
+ "markdown": "| Field | Meaning |\n|---|---|\n| `kind` | `LinkKind.direct` (a link opened the app) or `LinkKind.deferred` (tapped before install) |\n| `route` | `app_link`, `custom_scheme`, `install_referrer`, `fingerprint` or `clipboard` (`route.value`) |\n| `appState` | `closed`, `background` or `foreground` |\n| `matched`, `reason` | When `matched` is false: `not_found`, `expired`, `password_protected`, `no_match`, `network`, `invalid_url` |\n| `rawUrl` | The URL the OS handed the app |\n| `url`, `path`, `params` | The destination to navigate to |\n| `linkId` | The id of the Strait link |\n| `id` | This open's id (not the link's), shared with its `onLinkStart` signal |\n| `ms`, `at` | Resolve time in milliseconds, and arrival time (epoch milliseconds) |"
1306
+ },
1307
+ {
1308
+ "page": "Flutter SDK reference",
1309
+ "group": "Platforms",
1310
+ "section": "Deferred match only",
1311
+ "url": "https://straitlink.in/docs/sdks/flutter/reference/#deferred-match-only",
1312
+ "markdown": "```dart\nfinal result = await resolveDeferredLink(\n publishableKey: 'st_pub_live_…',\n endpoint: 'https://hilltop.strait.link',\n platform: Platform.isIOS ? 'ios' : 'android',\n device: device, // DeviceFields as in Install & set up\n installReferrer: referrer, // Android only; null otherwise\n);\nif (result.matched && result.longUrl != null) { /* route */ }\n```\n\nIt never throws; on any error it returns `MatchResult.none`."
1313
+ },
1314
+ {
1315
+ "page": "Android deep linking (Kotlin)",
1316
+ "group": "Platforms",
1317
+ "section": null,
1318
+ "url": "https://straitlink.in/docs/sdks/android/",
1319
+ "markdown": "The SDK is plain Kotlin and depends only on the Kotlin standard library and Android's built-in `org.json`. Everything is in the `dev.strait.sdk` package."
1320
+ },
1321
+ {
1322
+ "page": "Android deep linking (Kotlin)",
1323
+ "group": "Platforms",
1324
+ "section": "1. Add the dependencies",
1325
+ "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."
1327
+ },
1328
+ {
1329
+ "page": "Android deep linking (Kotlin)",
1330
+ "group": "Platforms",
1331
+ "section": "2. Read the Play Install Referrer",
1332
+ "url": "https://straitlink.in/docs/sdks/android/#2-read-the-play-install-referrer",
1333
+ "markdown": "This is how a link tapped before the install reaches the app. The SDK calls it once, off the main thread:\n\n```kotlin title=\"InstallReferrer.kt\"\nimport android.content.Context\nimport com.android.installreferrer.api.InstallReferrerClient\nimport com.android.installreferrer.api.InstallReferrerStateListener\n\nfun readInstallReferrer(context: Context): String? {\n val latch = java.util.concurrent.CountDownLatch(1)\n var result: String? = null\n val client = InstallReferrerClient.newBuilder(context).build()\n try {\n client.startConnection(object : InstallReferrerStateListener {\n override fun onInstallReferrerSetupFinished(code: Int) {\n if (code == InstallReferrerClient.InstallReferrerResponse.OK) {\n result = runCatching { client.installReferrer.installReferrer }.getOrNull()\n }\n latch.countDown()\n }\n override fun onInstallReferrerServiceDisconnected() { latch.countDown() }\n })\n latch.await(5, java.util.concurrent.TimeUnit.SECONDS)\n } catch (_: Exception) {\n } finally {\n runCatching { client.endConnection() }\n }\n return result\n}\n```\n\n**You should see:** the file compiles."
1334
+ },
1335
+ {
1336
+ "page": "Android deep linking (Kotlin)",
1337
+ "group": "Platforms",
1338
+ "section": "3. Create the client once, in your Application",
1339
+ "url": "https://straitlink.in/docs/sdks/android/#3-create-the-client-once-in-your-application",
1340
+ "markdown": "Network calls block the calling thread, so pass a background `executor`. Listeners run on it too, so post to the main thread before you navigate.\n\n```kotlin title=\"App.kt\"\nimport android.app.Application\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.lifecycle.ProcessLifecycleOwner\nimport dev.strait.sdk.AppLifecycleState\nimport dev.strait.sdk.DeviceFields\nimport dev.strait.sdk.KeyValueStore\nimport dev.strait.sdk.StraitClient\nimport dev.strait.sdk.StraitConfig\nimport dev.strait.sdk.portraitScreenWidth\n\nclass App : Application() {\n lateinit var links: StraitClient\n\n override fun onCreate() {\n super.onCreate()\n val prefs = getSharedPreferences(\"links\", MODE_PRIVATE)\n links = StraitClient(StraitConfig(\n publishableKey = \"st_pub_live_…\", // Dashboard → Get started\n endpoint = \"https://hilltop.strait.link\", // your workspace's link domain\n storage = object : KeyValueStore {\n override fun get(key: String) = prefs.getString(key, null)\n override fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }\n },\n installReferrer = { readInstallReferrer(this) },\n device = {\n val m = resources.displayMetrics\n DeviceFields(\n screenWidth = portraitScreenWidth(m.widthPixels / m.density.toDouble(), m.heightPixels / m.density.toDouble()),\n pixelRatio = m.density.toDouble(),\n language = java.util.Locale.getDefault().toLanguageTag(),\n timezone = java.util.TimeZone.getDefault().id,\n )\n },\n executor = java.util.concurrent.Executors.newSingleThreadExecutor(),\n ))\n\n // App state: background vs foreground labels, and retrying saved reports.\n ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, e ->\n when (e) {\n Lifecycle.Event.ON_RESUME -> links.onAppState(AppLifecycleState.ACTIVE)\n Lifecycle.Event.ON_PAUSE -> links.onAppState(AppLifecycleState.INACTIVE)\n Lifecycle.Event.ON_STOP -> links.onAppState(AppLifecycleState.BACKGROUND)\n else -> Unit\n }\n })\n }\n}\n```\n\nRegister it with `android:name=\".App\"` on `<application>` in the manifest.\n\n**You should see:** the app starts as before."
1341
+ },
1342
+ {
1343
+ "page": "Android deep linking (Kotlin)",
1344
+ "group": "Platforms",
1345
+ "section": "4. Add the intent filters",
1346
+ "url": "https://straitlink.in/docs/sdks/android/#4-add-the-intent-filters",
1347
+ "markdown": "```xml title=\"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\nVerified links also need your SHA-256 fingerprints in Apps & stores ([go-live checklist](https://straitlink.in/docs/production-checklist/)).\n\n**You should see:** `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` brings your app to the front. The next page feeds that URL to the SDK."
1348
+ },
1349
+ {
1350
+ "page": "Android deep linking (Kotlin)",
1351
+ "group": "Platforms",
1352
+ "section": "Or let `strait init` write the setup",
1353
+ "url": "https://straitlink.in/docs/sdks/android/#or-let-strait-init-write-the-setup",
1354
+ "markdown": "From your project's folder:\n\n```bash\nnpx @straitlink/cli init --key st_pub_live_… --host hilltop.strait.link\n```\n\nIt finds your package name in Gradle, writes `StraitLinks.kt` next to your code with your key and host and the Play Install Referrer wired in, and prints the Gradle lines and intent filters, filled in. It never edits your existing files."
1355
+ },
1356
+ {
1357
+ "page": "Android deep linking (Kotlin)",
1358
+ "group": "Platforms",
1359
+ "section": "Common errors",
1360
+ "url": "https://straitlink.in/docs/sdks/android/#common-errors",
1361
+ "markdown": "- **`Could not find com.github.bazingga08:strait-sdk-android`.** The JitPack repository is missing from `settings.gradle.kts`, or a project-level `repositories` block overrides it.\n- **`NetworkOnMainThreadException`.** No `executor` was passed, so `start` ran on the main thread.\n- **`UninitializedPropertyAccessException` for `links`.** The manifest doesn't name `.App` as the application class."
1362
+ },
1363
+ {
1364
+ "page": "Android deep linking (Kotlin)",
1365
+ "group": "Platforms",
1366
+ "section": "Next steps",
1367
+ "url": "https://straitlink.in/docs/sdks/android/#next-steps",
1368
+ "markdown": "- [Receive links](https://straitlink.in/docs/sdks/android/receive-links/): pass each intent to the SDK and open the right screen.\n- [Test](https://straitlink.in/docs/sdks/android/test/): deep links, deferred links from Google Play, and analytics."
1369
+ },
1370
+ {
1371
+ "page": "Receive links on Android",
1372
+ "group": "Platforms",
1373
+ "section": "1. Feed the SDK the launch intent and new intents",
1374
+ "url": "https://straitlink.in/docs/sdks/android/receive-links/#1-feed-the-sdk-the-launch-intent-and-new-intents",
1375
+ "markdown": "```kotlin title=\"MainActivity.kt\"\nimport android.content.Intent\nimport android.os.Bundle\nimport androidx.appcompat.app.AppCompatActivity\n\nclass MainActivity : AppCompatActivity() {\n private val links get() = (application as App).links\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n links.onLinkStart { runOnUiThread { showOpeningLink() } }\n links.onLink { e -> runOnUiThread { if (e.matched) navigate(e.path, e.params) else hideOpeningLink() } }\n // Launched from closed: the intent's URL (or null). Runs the deferred check once per install.\n if (savedInstanceState == null) links.start(intent?.dataString)\n }\n\n override fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n intent.dataString?.let(links::handleUrl) // app was already running\n }\n\n // Replace these three with your own loading UI and navigation.\n private fun showOpeningLink() { /* show a spinner */ }\n private fun hideOpeningLink() { /* hide it */ }\n private fun navigate(path: String?, params: Map<String, String>?) {\n hideOpeningLink()\n val id = path?.removePrefix(\"/product/\")?.takeIf { it != path } ?: return\n startActivity(Intent(this, ProductActivity::class.java).putExtra(\"id\", id))\n }\n}\n```\n\n`onLink` replays past events to late subscribers, and both `onLink` and `onLinkStart` return an unsubscribe function.\n\n**You should see:** with the app closed, `adb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42\"` opens `ProductActivity` with `id` `42`."
1376
+ },
1377
+ {
1378
+ "page": "Receive links on Android",
1379
+ "group": "Platforms",
1380
+ "section": "2. Check links that arrive while the app is open",
1381
+ "url": "https://straitlink.in/docs/sdks/android/receive-links/#2-check-links-that-arrive-while-the-app-is-open",
1382
+ "markdown": "Keep the app in the foreground and run the same `adb` command again. `singleTask` delivers it to `onNewIntent`.\n\n**You should see:** `ProductActivity` opens again, and the event's `appState` is `foreground` (or `background` if you'd left the app)."
1383
+ },
1384
+ {
1385
+ "page": "Receive links on Android",
1386
+ "group": "Platforms",
1387
+ "section": "3. Handle links that don't match",
1388
+ "url": "https://straitlink.in/docs/sdks/android/receive-links/#3-handle-links-that-dont-match",
1389
+ "markdown": "When `matched` is false, `reason` says why: `not_found`, `expired`, `password_protected`, `no_match`, `network` or `invalid_url`. The activity is already showing, so just hide the spinner, as above.\n\n**You should see:** a link to a slug that doesn't exist leaves you on the main screen with no spinner."
1390
+ },
1391
+ {
1392
+ "page": "Receive links on Android",
1393
+ "group": "Platforms",
1394
+ "section": "Common errors",
1395
+ "url": "https://straitlink.in/docs/sdks/android/receive-links/#common-errors",
1396
+ "markdown": "- **Links open a second copy of the activity.** `android:launchMode=\"singleTask\"` is missing, so `onNewIntent` never runs.\n- **The deferred check runs again after rotation.** Call `start` only when `savedInstanceState == null`, as above.\n- **`CalledFromWrongThreadException`.** Navigation ran on the SDK's executor. Wrap it in `runOnUiThread`."
1397
+ },
1398
+ {
1399
+ "page": "Receive links on Android",
1400
+ "group": "Platforms",
1401
+ "section": "Next steps",
1402
+ "url": "https://straitlink.in/docs/sdks/android/receive-links/#next-steps",
1403
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/android/events/): record purchases on the tap that brought them.\n- [Reference](https://straitlink.in/docs/sdks/android/reference/): every field of `LinkEvent` and every option."
1404
+ },
1405
+ {
1406
+ "page": "Create links from an Android app",
1407
+ "group": "Platforms",
1408
+ "section": null,
1409
+ "url": "https://straitlink.in/docs/sdks/android/create-links/",
1410
+ "markdown": "Links are created with the **secret key**, which must never ship in an app. The app asks your server; your server calls `POST /v1/links`."
1411
+ },
1412
+ {
1413
+ "page": "Create links from an Android app",
1414
+ "group": "Platforms",
1415
+ "section": "1. Add an endpoint to your server",
1416
+ "url": "https://straitlink.in/docs/sdks/android/create-links/#1-add-an-endpoint-to-your-server",
1417
+ "markdown": "Use the server code from the [React Native page](https://straitlink.in/docs/sdks/react-native/create-links/#1-add-an-endpoint-to-your-server) (Node), or call the API from any language:\n\n```bash\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"slug\": \"p42-7f3e2c1a\", \"longUrl\": \"https://shop.example.com/product/42?ref=user_123\", \"campaign\": \"in_app_share\" }'\n```\n\nSlugs are unique within your workspace, so your server should add a random part, and reply to the app with the `shortUrl`.\n\n**You should see:** a `201` answer with `\"shortUrl\": \"https://hilltop.strait.link/p42-7f3e2c1a\"`."
1418
+ },
1419
+ {
1420
+ "page": "Create links from an Android app",
1421
+ "group": "Platforms",
1422
+ "section": "2. Call it from the app and share",
1423
+ "url": "https://straitlink.in/docs/sdks/android/create-links/#2-call-it-from-the-app-and-share",
1424
+ "markdown": "```kotlin title=\"Share.kt\"\nimport android.app.Activity\nimport android.content.Intent\nimport java.net.HttpURLConnection\nimport java.net.URL\nimport org.json.JSONObject\n\nfun Activity.shareProduct(productId: String) = Thread {\n val conn = URL(\"https://api.yourapp.com/share\").openConnection() as HttpURLConnection // your server\n conn.requestMethod = \"POST\"\n conn.doOutput = true\n conn.setRequestProperty(\"Content-Type\", \"application/json\") // add your own auth header\n conn.outputStream.use { it.write(JSONObject().put(\"productId\", productId).toString().toByteArray()) }\n val url = JSONObject(conn.inputStream.bufferedReader().readText()).getString(\"url\")\n runOnUiThread {\n val send = Intent(Intent.ACTION_SEND).setType(\"text/plain\").putExtra(Intent.EXTRA_TEXT, url)\n startActivity(Intent.createChooser(send, \"Share\"))\n }\n}.start()\n```\n\n**You should see:** the share sheet with a `https://hilltop.strait.link/…` link. Tap it on another phone: the app opens on that product."
1425
+ },
1426
+ {
1427
+ "page": "Create links from an Android app",
1428
+ "group": "Platforms",
1429
+ "section": "3. Read the referrer when the link opens the app",
1430
+ "url": "https://straitlink.in/docs/sdks/android/create-links/#3-read-the-referrer-when-the-link-opens-the-app",
1431
+ "markdown": "```kotlin\nlinks.onLink { e -> e.params?.get(\"ref\")?.takeIf { e.matched }?.let(::saveReferrer) } // your own code\n```\n\n**You should see:** `ref` set to the sharer's id when a friend opens the link, including after installing from Google Play."
1432
+ },
1433
+ {
1434
+ "page": "Create links from an Android app",
1435
+ "group": "Platforms",
1436
+ "section": "Common errors",
1437
+ "url": "https://straitlink.in/docs/sdks/android/create-links/#common-errors",
1438
+ "markdown": "- **`401` from Strait.** Your server sent the publishable key. Creating links needs a secret key.\n- **`409`.** The slug is already used in your workspace. Make the random part longer.\n- **`NetworkOnMainThreadException`.** The request ran on the main thread. Keep it in the background thread, as above."
1439
+ },
1440
+ {
1441
+ "page": "Create links from an Android app",
1442
+ "group": "Platforms",
1443
+ "section": "Next steps",
1444
+ "url": "https://straitlink.in/docs/sdks/android/create-links/#next-steps",
1445
+ "markdown": "- [Events & revenue](https://straitlink.in/docs/sdks/android/events/): see which shares led to purchases.\n- [Create a link (API)](https://straitlink.in/docs/api/create-link/): every field, including fallbacks, previews and expiry."
1446
+ },
1447
+ {
1448
+ "page": "Events and revenue on Android",
1449
+ "group": "Platforms",
1450
+ "section": "1. Know what's already reported",
1451
+ "url": "https://straitlink.in/docs/sdks/android/events/#1-know-whats-already-reported",
1452
+ "markdown": "Every link open is reported once, joined to its tap where possible: verified links from chat apps, browser hand-offs, the first open after a Play install, and your own https links (host and path only). Reports that can't be sent are saved under `storage` and retried on the next `start`, whenever `onAppState(ACTIVE)` is called, and after any report that gets through, for up to 7 days (100 at most).\n\n**You should see:** after tapping a link, **Dashboard → Analytics** shows the tap and the app open within a minute."
1453
+ },
1454
+ {
1455
+ "page": "Events and revenue on Android",
1456
+ "group": "Platforms",
1457
+ "section": "2. Record a purchase (off the main thread)",
1458
+ "url": "https://straitlink.in/docs/sdks/android/events/#2-record-a-purchase-off-the-main-thread",
1459
+ "markdown": "`trackEvent` is a blocking call that returns `true` when the event was accepted:\n\n```kotlin\nThread { links.trackEvent(\"purchase\", value = 49.99, currency = \"USD\") }.start()\n```\n\nUse any event name; `value`, `currency`, `linkId` and `clickId` are optional.\n\n**You should see:** the purchase and its revenue at the bottom of the funnel in **Analytics**."
1460
+ },
1461
+ {
1462
+ "page": "Events and revenue on Android",
1463
+ "group": "Platforms",
1464
+ "section": "3. Understand which tap gets the credit",
1465
+ "url": "https://straitlink.in/docs/sdks/android/events/#3-understand-which-tap-gets-the-credit",
1466
+ "markdown": "The event carries the tap id of the most recent link open, as long as that open was in the last 7 days. The tap id comes from a browser hand-off to `yourapp://…?strait_click=…`, a Google Play install, or an open from a verified short link. When our server matches an open or an install, it sends the tap id back with its answer. That lets Analytics put the revenue on the exact tap's channel and A/B variant. Each newer open replaces the remembered tap with its own, so an older tap never takes credit for a newer open; if an open comes with no tap id, the remembered one is forgotten and the event carries none. To name the tap yourself, pass `clickId =`, which overrides the remembered one. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\n**You should see:** two links tapped one after the other credit a purchase to the second one only."
1467
+ },
1468
+ {
1469
+ "page": "Events and revenue on Android",
1470
+ "group": "Platforms",
1471
+ "section": "Common errors",
1472
+ "url": "https://straitlink.in/docs/sdks/android/events/#common-errors",
1473
+ "markdown": "- **`NetworkOnMainThreadException`.** `trackEvent` ran on the main thread. Call it from a background thread or coroutine.\n- **The purchase shows as direct.** The last link open was more than 7 days ago, or the app was opened without a link since.\n- **`trackEvent` returns `false`.** The phone was offline or the key is wrong. Events aren't queued, so retry from your code if it matters."
1474
+ },
1475
+ {
1476
+ "page": "Events and revenue on Android",
1477
+ "group": "Platforms",
1478
+ "section": "Next steps",
1479
+ "url": "https://straitlink.in/docs/sdks/android/events/#next-steps",
1480
+ "markdown": "- [Analytics: how we count](https://straitlink.in/docs/analytics/): taps, opens, installs and revenue, defined.\n- [Webhooks](https://straitlink.in/docs/webhooks/): get each open and install on your server as it happens."
1481
+ },
1482
+ {
1483
+ "page": "Test deep links on Android",
1484
+ "group": "Platforms",
1485
+ "section": "1. Test the routing without a tap",
1486
+ "url": "https://straitlink.in/docs/sdks/android/test/#1-test-the-routing-without-a-tap",
1487
+ "markdown": "```bash\n# Browser hand-off (custom scheme)\nadb shell am start -W -a android.intent.action.VIEW -d \"yourapp://shop.example.com/product/42?color=red\"\n\n# Verified link\nadb shell am start -W -a android.intent.action.VIEW -d \"https://hilltop.strait.link/summer\"\n```\n\n**You should see:** `ProductActivity` for `42`, once with the app closed and once from the background."
1488
+ },
1489
+ {
1490
+ "page": "Test deep links on Android",
1491
+ "group": "Platforms",
1492
+ "section": "2. Check App Links verification",
1493
+ "url": "https://straitlink.in/docs/sdks/android/test/#2-check-app-links-verification",
1494
+ "markdown": "```bash\nadb shell pm get-app-links com.yourcompany.yourapp\n```\n\n**You should see:** `hilltop.strait.link: verified`. If it says `1024` or `none`, the installed build's certificate isn't in Apps & stores ([go-live checklist, step 1](https://straitlink.in/docs/production-checklist/#1-add-every-sha-256-fingerprint)). Reinstall after fixing it: Android checks at install time."
1495
+ },
1496
+ {
1497
+ "page": "Test deep links on Android",
1498
+ "group": "Platforms",
1499
+ "section": "3. Tap a real link from a chat app",
1500
+ "url": "https://straitlink.in/docs/sdks/android/test/#3-tap-a-real-link-from-a-chat-app",
1501
+ "markdown": "Send `https://hilltop.strait.link/summer` to yourself in Google Messages, Gmail and WhatsApp, and tap it in each.\n\n**You should see:** the app opens on the link's screen with no browser in between (verified), or after a brief browser flash (hand-off). WhatsApp and Instagram may show an **Open in app** page first."
1502
+ },
1503
+ {
1504
+ "page": "Test deep links on Android",
1505
+ "group": "Platforms",
1506
+ "section": "4. Ask what a tap would do",
1507
+ "url": "https://straitlink.in/docs/sdks/android/test/#4-ask-what-a-tap-would-do",
1508
+ "markdown": "```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** a `decision` of `redirect`, a `location` starting `intent://`, and a plain-English `reason`. No tap is recorded. See [Dry-run a tap](https://straitlink.in/docs/api/simulate-tap/)."
1509
+ },
1510
+ {
1511
+ "page": "Test deep links on Android",
1512
+ "group": "Platforms",
1513
+ "section": "5. Test a deferred link from Google Play",
1514
+ "url": "https://straitlink.in/docs/sdks/android/test/#5-test-a-deferred-link-from-google-play",
1515
+ "markdown": "Install from Google Play internal testing ([go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play)). Uninstall, tap the link, install from Play and open the app. To check without reinstalling, `links.checkDeferred()` re-runs the check off the main thread and never records an install.\n\n**You should see:** the first screen is the link's screen, with `kind` `deferred` and `route` `install_referrer`."
1516
+ },
1517
+ {
1518
+ "page": "Test deep links on Android",
1519
+ "group": "Platforms",
1520
+ "section": "Common errors",
1521
+ "url": "https://straitlink.in/docs/sdks/android/test/#common-errors",
1522
+ "markdown": "- **The deferred test opens the home screen.** The build came from Android Studio, not Play, or \"When someone without the app taps a link\" isn't **Always Google Play** while the app is unlisted.\n- **Samsung Internet keeps the link in the browser.** Turn on **Settings → Useful features → Open links in other apps**.\n- **Firefox or Edge asks before opening the app.** That's the browser's choice and can't be skipped. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/)."
1523
+ },
1524
+ {
1525
+ "page": "Test deep links on Android",
1526
+ "group": "Platforms",
1527
+ "section": "Next steps",
1528
+ "url": "https://straitlink.in/docs/sdks/android/test/#next-steps",
1529
+ "markdown": "- [Go-live checklist](https://straitlink.in/docs/production-checklist/): everything to finish before real users tap your links.\n- [Reference](https://straitlink.in/docs/sdks/android/reference/): every option, method and event field."
1530
+ },
1531
+ {
1532
+ "page": "Android SDK reference",
1533
+ "group": "Platforms",
1534
+ "section": "Requirements",
1535
+ "url": "https://straitlink.in/docs/sdks/android/reference/#requirements",
1536
+ "markdown": "The SDK is plain Kotlin, compiled with Kotlin 1.9.24 to Java 17 bytecode, so build your app with JDK 17 or newer (Android Gradle Plugin 8 already needs it). At runtime it uses the Kotlin standard library and `org.json`, which ships with Android."
1537
+ },
1538
+ {
1539
+ "page": "Android SDK reference",
1540
+ "group": "Platforms",
1541
+ "section": "StraitConfig",
1542
+ "url": "https://straitlink.in/docs/sdks/android/reference/#straitconfig",
1543
+ "markdown": "| Parameter | Type | Notes |\n|---|---|---|\n| `publishableKey` | `String` | **Required.** `st_pub_live_…`. Never the secret key |\n| `endpoint` | `String` | **Required.** Your link domain, `https://<handle>.strait.link` |\n| `device` | `() -> DeviceFields` | **Required.** Screen width (`portraitScreenWidth`), pixel ratio, language, time zone |\n| `storage` | `KeyValueStore` | Persists the once-per-install flag and unsent reports. Defaults to memory only |\n| `installReferrer` | `() -> String?` | Blocking read of the Play Install Referrer. Called off the main thread |\n| `executor` | `Executor` | Where network work and listeners run. Pass a background executor |\n| `linkHosts` | `List<String>` | Extra hosts that serve your links |\n| `transport` | `HttpTransport` | Defaults to `HttpURLConnection` |"
1544
+ },
1545
+ {
1546
+ "page": "Android SDK reference",
1547
+ "group": "Platforms",
1548
+ "section": "StraitClient",
1549
+ "url": "https://straitlink.in/docs/sdks/android/reference/#straitclient",
1550
+ "markdown": "| Method | What it does |\n|---|---|\n| `start(initialUrl)` | Handles the launch URL (or null) and runs the deferred check once per install |\n| `handleUrl(url)` | A link arrived while the app was running (`onNewIntent`) |\n| `onLink { e -> }` / `onLinkStart { s -> }` | Link events (replayed to late subscribers) and the \"resolving\" signal. Both return an unsubscribe function |\n| `onAppState(state)` | `ACTIVE`, `INACTIVE` or `BACKGROUND`, for app-state labels and retries |\n| `trackEvent(name, value, currency, linkId, clickId)` | Blocking. `true` when accepted |\n| `checkDeferred()` | Re-runs the deferred check (debugging). Never records an install |\n| `pendingOpenReports()` / `flushOpenReports()` | Count, or send now, the reports saved while offline |\n| `stop()` | Stops listening |"
1551
+ },
1552
+ {
1553
+ "page": "Android SDK reference",
1554
+ "group": "Platforms",
1555
+ "section": "LinkEvent",
1556
+ "url": "https://straitlink.in/docs/sdks/android/reference/#linkevent",
1557
+ "markdown": "| Field | Meaning |\n|---|---|\n| `id` | This open's id (not the link's) |\n| `kind` | `direct` or `deferred` |\n| `route` | `app_link`, `custom_scheme`, `install_referrer` or `fingerprint` |\n| `appState` | `closed`, `background` or `foreground` |\n| `matched`, `reason` | When `matched` is false: `not_found`, `expired`, `password_protected`, `no_match`, `network`, `invalid_url` |\n| `rawUrl` | The URL the OS gave the app |\n| `url`, `path`, `params` | The destination to navigate to |\n| `linkId` | The id of the Strait link |\n| `ms`, `at` | Resolve time in milliseconds, arrival time (epoch milliseconds) |"
1558
+ },
1559
+ {
1560
+ "page": "Android SDK reference",
1561
+ "group": "Platforms",
1562
+ "section": "Custom HTTP",
1563
+ "url": "https://straitlink.in/docs/sdks/android/reference/#custom-http",
1564
+ "markdown": "To use OkHttp, implement `HttpTransport`: throw on network failure and return every HTTP status as an `HttpResponse`."
1565
+ },
1566
+ {
1567
+ "page": "Android SDK reference",
1568
+ "group": "Platforms",
1569
+ "section": "How installs are matched",
1570
+ "url": "https://straitlink.in/docs/sdks/android/reference/#how-installs-are-matched",
1571
+ "markdown": "When someone without your app taps a link, we send them to Google Play with the link in the install referrer. On first open the SDK reads it and resolves the exact link and tap. No guessing. See [Deferred deep linking](https://straitlink.in/docs/deferred-deep-linking/)."
1572
+ },
1573
+ {
1574
+ "page": "iOS (Swift)",
1575
+ "group": "Platforms",
1576
+ "section": null,
1577
+ "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:::"
1579
+ },
1580
+ {
1581
+ "page": "iOS (Swift)",
1582
+ "group": "Platforms",
1583
+ "section": "Install",
1584
+ "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."
1586
+ },
1587
+ {
1588
+ "page": "iOS (Swift)",
1589
+ "group": "Platforms",
1590
+ "section": "Create the client",
1591
+ "url": "https://straitlink.in/docs/sdks/ios/#create-the-client",
1592
+ "markdown": "Create one client at launch and keep it for the app's lifetime.\n\n```swift\nimport StraitSDK\n\nlet links = StraitLinks(StraitLinksConfig(\n publishableKey: \"st_pub_live_…\", // Dashboard → Get started\n endpoint: \"https://hilltop.strait.link\" // your workspace's link domain\n))\n\nlinks.onLinkStart { start in showOpeningLink(start.id) } // loading state\nlinks.onLink { event in // replays past events\n guard event.matched, let path = event.path else { return }\n router.open(path, params: event.params ?? [:])\n}\n```"
1593
+ },
1594
+ {
1595
+ "page": "iOS (Swift)",
1596
+ "group": "Platforms",
1597
+ "section": "Handle deep links",
1598
+ "url": "https://straitlink.in/docs/sdks/ios/#handle-deep-links",
1599
+ "markdown": "Pass every way a link reaches the app to the SDK: the launch link, Universal Links while running, and `yourapp://…` browser hand-offs. It resolves short links, reports the open and calls `onLink` once per link.\n\n### UIKit with scenes\n\n```swift title=\"SceneDelegate.swift\"\nfunc scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {\n // The link that launched the app from closed (Universal Link or custom scheme).\n let launchURL = options.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb })?.webpageURL\n ?? options.urlContexts.first?.url\n links.start(initialURL: launchURL) // also runs the deferred check, once per install\n}\n\nfunc scene(_ scene: UIScene, continue userActivity: NSUserActivity) {\n links.handle(userActivity: userActivity) // Universal Link while running\n}\n\nfunc scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {\n URLContexts.forEach { links.handle(url: $0.url) } // yourapp://… browser hand-off\n}\n```\n\nWithout scenes, do the same from `application(_:didFinishLaunchingWithOptions:)`, `application(_:continue:restorationHandler:)` and `application(_:open:options:)`.\n\n### SwiftUI\n\n```swift\n@main struct ShopApp: App {\n init() { links.start() }\n var body: some Scene {\n WindowGroup {\n ContentView()\n .onOpenURL { links.handle(url: $0) }\n .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { links.handle(userActivity: $0) }\n }\n }\n}\n```\n\nSwiftUI delivers the cold-launch URL through `onOpenURL` too, so it's labelled by app state rather than `closed`. If you need `closed` (and the first-launch deferred skip), use a scene delegate and pass the launch URL to `start(initialURL:)`."
1600
+ },
1601
+ {
1602
+ "page": "iOS (Swift)",
1603
+ "group": "Platforms",
1604
+ "section": "Deferred deep links",
1605
+ "url": "https://straitlink.in/docs/sdks/ios/#deferred-deep-links",
1606
+ "markdown": "`start` runs the deferred check once per install, on the first launch, unless that launch was itself opened by a link. A match arrives through `onLink` like any other link, with `kind` `.deferred`. If the check gets no answer (offline, `429`, server error), it runs again on the next launch.\n\n### How the iPhone match works\n\nWhen someone without your app taps a link in Safari, they see a page with a **Get the app** button. Tapping it saves a coarse, short-lived device match (screen width, pixel ratio, language, time zone, plus the network the request came from) and opens the App Store. On first launch the SDK sends the same fields; if they match within **1 hour**, your app gets the link. Whether device matching, paste handoff (an exact match the person allows, read from the clipboard), both or neither are used is your choice in **Dashboard → Settings → iPhone installs**. The SDK reads it from the server on the first launch, so changing it needs no app release; the clipboard is read only when paste handoff is on and device matching found nothing. `clipboardBoost` is deprecated and ignored. What we use, the four combinations and a ready-made App Store privacy label: [How iPhone install matching works](https://straitlink.in/docs/iphone-install-matching/).\n\n### Deferred match only\n\nWithout the client, for an app that only wants the first-launch match:\n\n```swift\nlet result = await Strait.resolveDeferredLink(\n StraitConfig(publishableKey: \"st_pub_live_…\", endpoint: \"https://hilltop.strait.link\")\n)\nif result.matched, let url = result.longUrl {\n // route to url\n}\n```"
1607
+ },
1608
+ {
1609
+ "page": "iOS (Swift)",
1610
+ "group": "Platforms",
1611
+ "section": "Events",
1612
+ "url": "https://straitlink.in/docs/sdks/ios/#events",
1613
+ "markdown": "```swift\nlinks.trackEvent(\"purchase\", value: 49.99, currency: \"USD\", linkId: event.linkId) { ok in }\nlinks.checkDeferred { event in } // re-run the deferred check (debugging)\nlinks.pendingOpenReports { count in } // saved reports waiting to be sent\nlinks.flushOpenReports { } // send them now\n```\n\nThe event carries the tap id of the most recent link open, as long as that open was in the last 7 days. The tap id comes from a browser hand-off to `yourapp://…?strait_click=…`, an open from a verified short link (Universal Link), or an install match. When our server matches an open or an install, it sends the tap id back with its answer. That lets Analytics put the revenue on the exact tap's channel and A/B variant. Each newer open replaces the remembered tap with its own, so an older tap never takes credit for a newer open; if an open comes with no tap id, the remembered one is forgotten and the event carries none. To name the tap yourself, pass `clickId:`, which overrides the remembered one. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\nThe once-per-install flag and pending open reports live in `UserDefaults.standard` by default; pass any `StraitStorage` to change that. Callbacks run on the main queue."
1614
+ },
1615
+ {
1616
+ "page": "iOS (Swift)",
1617
+ "group": "Platforms",
1618
+ "section": "Common errors",
1619
+ "url": "https://straitlink.in/docs/sdks/ios/#common-errors",
1620
+ "markdown": "- **A link opens Safari instead of the app.** `applinks:hilltop.strait.link` is missing from Associated Domains, or your Apple Team ID and bundle ID aren't saved in **Dashboard → Apps & stores**, so `apple-app-site-association` doesn't name your app. iOS caches that file through Apple's CDN, so a fix can take a while to show. More in [Troubleshooting](https://straitlink.in/docs/troubleshooting/#the-link-opens-the-browser-instead-of-the-app).\n- **`matched` is `false`.** Read `reason`: `not_found` (wrong slug or another workspace's key), `expired`, `password_protected` (password links can't open in the app), or `network` (no answer; the open is saved and sent later). [All reasons](https://straitlink.in/docs/troubleshooting/#the-app-opened-but-matched-is-false).\n- **The deferred link never arrives.** The person must tap **Get the app** on our page in Safari and open the app within an hour, on the same network. The check runs once per install: delete and reinstall the app to test again, or call `links.checkDeferred` while debugging.\n- **`appState` is never `closed` with SwiftUI.** SwiftUI delivers the launch link through `onOpenURL`. Use a scene delegate and `start(initialURL:)`, as above."
1621
+ },
1622
+ {
1623
+ "page": "Unity",
1624
+ "group": "Platforms",
1625
+ "section": null,
1626
+ "url": "https://straitlink.in/docs/sdks/unity/",
1627
+ "markdown": ":::warn Check on a device before you ship\nThe library itself is tested in CI. The Unity glue below (MonoBehaviour, PlayerPrefs, AndroidJavaObject, the iOS plugin, UnityWebRequest) hasn't been run in the Unity editor or on a device yet. Treat it as a documented starting point.\n:::"
1628
+ },
1629
+ {
1630
+ "page": "Unity",
1631
+ "group": "Platforms",
1632
+ "section": "Install",
1633
+ "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)."
1635
+ },
1636
+ {
1637
+ "page": "Unity",
1638
+ "group": "Platforms",
1639
+ "section": "Create the client",
1640
+ "url": "https://straitlink.in/docs/sdks/unity/#create-the-client",
1641
+ "markdown": "Create one client in a MonoBehaviour that lives for the whole game:\n\n```csharp title=\"LinksBehaviour.cs\"\nusing System.Threading.Tasks;\nusing Strait;\nusing UnityEngine;\n\npublic class LinksBehaviour : MonoBehaviour\n{\n public static StraitClient Client { get; private set; }\n\n async void Start()\n {\n DontDestroyOnLoad(gameObject);\n Client = new StraitClient(new StraitConfig\n {\n PublishableKey = \"st_pub_live_…\", // Dashboard → Get started\n Endpoint = \"https://hilltop.strait.link\", // your workspace's link domain\n Storage = new PlayerPrefsStore(),\n Platform = Application.platform == RuntimePlatform.Android ? \"android\"\n : Application.platform == RuntimePlatform.IPhonePlayer ? \"ios\" : \"other\",\n DeviceFields = StraitDevice.Collect,\n InstallReferrer = Application.platform == RuntimePlatform.Android ? PlayReferrer.Get : null,\n // Transport = new UnityWebRequestTransport(), // required on WebGL\n });\n\n Client.OnLinkStart += s => ShowSpinner(); // a link can take 1–6 s to resolve\n Client.OnLink += e => // past events are replayed to you\n {\n HideSpinner();\n if (e.Matched) Route(e.Path, e.Params);\n else Debug.Log($\"Link not matched ({e.Reason})\");\n };\n\n Application.deepLinkActivated += url => _ = Client.HandleUrl(url); // while running\n await Client.Start(Application.absoluteURL); // launch link + deferred check\n }\n\n // Coming back to the front also sends open reports saved while offline.\n void OnApplicationPause(bool paused) => Client?.OnAppState(paused ? AppLifecycle.Background : AppLifecycle.Active);\n void OnApplicationFocus(bool focused) => Client?.OnAppState(focused ? AppLifecycle.Active : AppLifecycle.Inactive);\n void OnDestroy() => Client?.Stop();\n\n void ShowSpinner() { }\n void HideSpinner() { }\n void Route(string path, System.Collections.Generic.IReadOnlyDictionary<string, string> query) { }\n}\n```"
1642
+ },
1643
+ {
1644
+ "page": "Unity",
1645
+ "group": "Platforms",
1646
+ "section": "Handle deep links",
1647
+ "url": "https://straitlink.in/docs/sdks/unity/#handle-deep-links",
1648
+ "markdown": "`Start(Application.absoluteURL)` handles the link that launched the game, and `Application.deepLinkActivated` hands the SDK every link that arrives while it runs. The SDK resolves short links, reports the open, and raises `OnLink` once per link with `Path` and `Params` for your router. `OnLinkStart` fires first, with the same `Id`, so you can show a loading state. Past events are replayed to late subscribers.\n\nFor links to open the game, your link domain must be verified for it: on Android an App Links intent filter for `hilltop.strait.link` with `android:autoVerify=\"true\"` (in a custom `AndroidManifest.xml`), on iPhone the Associated Domains entry `applinks:hilltop.strait.link`. The same steps for native apps: [Android](https://straitlink.in/docs/sdks/android/), [iOS](https://straitlink.in/docs/sdks/ios/)."
1649
+ },
1650
+ {
1651
+ "page": "Unity",
1652
+ "group": "Platforms",
1653
+ "section": "Deferred deep links",
1654
+ "url": "https://straitlink.in/docs/sdks/unity/#deferred-deep-links",
1655
+ "markdown": "`Start` runs the deferred check once per install, on the first launch, unless that launch was itself opened by a link. On Android it reads the Google Play install referrer (`InstallReferrer = PlayReferrer.Get`); on iPhone it matches the device fields below. A match arrives through `OnLink` with `Kind` `deferred`. When the check gets no answer (`Reason` `network`), it runs again on the next launch. How the iPhone match works: [iPhone install matching](https://straitlink.in/docs/iphone-install-matching/)."
1656
+ },
1657
+ {
1658
+ "page": "Unity",
1659
+ "group": "Platforms",
1660
+ "section": "Storage",
1661
+ "url": "https://straitlink.in/docs/sdks/unity/#storage",
1662
+ "markdown": "```csharp\npublic sealed class PlayerPrefsStore : IKeyValueStore\n{\n public Task<string> GetItemAsync(string key) =>\n Task.FromResult(PlayerPrefs.HasKey(key) ? PlayerPrefs.GetString(key) : null);\n public Task SetItemAsync(string key, string value)\n {\n PlayerPrefs.SetString(key, value);\n PlayerPrefs.Save(); // the deferred flag and saved reports must survive a crash\n return Task.CompletedTask;\n }\n}\n```"
1663
+ },
1664
+ {
1665
+ "page": "Unity",
1666
+ "group": "Platforms",
1667
+ "section": "Device fields",
1668
+ "url": "https://straitlink.in/docs/sdks/unity/#device-fields",
1669
+ "markdown": "The iPhone match compares what the browser saw at tap time with what the game reports, so the values must be the browser's: CSS screen width (portrait, rounded like Chrome) via `StraitCore.PortraitScreenWidth(w, h)` (the shorter side, so a game first opened in landscape still matches), device pixel ratio, locale and IANA time zone. Don't use `Screen.width`, which follows Unity's render scaling. The SDK README has full `StraitDevice.Collect` (Android via `AndroidJavaObject`, iOS via a tiny `.mm` plugin), `PlayReferrer.Get` and `UnityWebRequestTransport` implementations."
1670
+ },
1671
+ {
1672
+ "page": "Unity",
1673
+ "group": "Platforms",
1674
+ "section": "Events",
1675
+ "url": "https://straitlink.in/docs/sdks/unity/#events",
1676
+ "markdown": "```csharp\nawait LinksBehaviour.Client.TrackEvent(\"purchase\", value: 4.99, currency: \"USD\", linkId: lastLink?.LinkId);\n```\n\n`TrackEvent` carries the tap id of the most recent link open, as long as that open was in the last 7 days. The tap id comes from a browser hand-off to `yourapp://…?strait_click=…`, a Google Play install, an open from a verified short link, or an iPhone install match. When our server matches an open or an install, it sends the tap id back with its answer. That lets Analytics put the revenue on the exact tap's channel and A/B variant. Each newer open replaces the remembered tap with its own, so an older tap never takes credit for a newer open; if an open comes with no tap id, the remembered one is forgotten and the event carries none. To name the tap yourself, pass `clickId:`, which overrides the remembered one. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\n`LinkEvent` has the same fields and values as the other SDKs: `Id, Kind, Route, AppState, Matched, Reason, RawUrl, Url, Path, Params, LinkId, Ms, At`. Call `Start` and `HandleUrl` from the main thread; events fire on Unity's main thread."
1677
+ },
1678
+ {
1679
+ "page": "Unity",
1680
+ "group": "Platforms",
1681
+ "section": "Common errors",
1682
+ "url": "https://straitlink.in/docs/sdks/unity/#common-errors",
1683
+ "markdown": "- **The package doesn't compile.** Set **Player Settings → Api Compatibility Level** to **.NET Standard 2.1** (Unity 2021.2 or newer).\n- **Links never resolve in a WebGL build.** Set `Transport = new UnityWebRequestTransport()`; WebGL needs it.\n- **The deferred link never arrives on Android.** The game must be installed from Google Play (internal testing is fine), and `com.android.installreferrer:installreferrer:2.2` must be in your Gradle dependencies.\n- **The deferred link never arrives on iPhone.** The device fields must be the browser's: use `StraitCore.PortraitScreenWidth`, not `Screen.width`.\n- **`Matched` is `false`.** Read `Reason`: `not_found`, `expired`, `password_protected`, `no_match`, `network` or `invalid_url`. What each means: [Troubleshooting](https://straitlink.in/docs/troubleshooting/#the-app-opened-but-matched-is-false)."
1684
+ },
1685
+ {
1686
+ "page": "Unity",
1687
+ "group": "Platforms",
1688
+ "section": "Next steps",
1689
+ "url": "https://straitlink.in/docs/sdks/unity/#next-steps",
1690
+ "markdown": "- [Deep links for mobile games](https://straitlink.in/solutions/gaming/): squad invites that land a new player in the same lobby after the install.\n- [Unity deep linking guide](https://straitlink.in/guides/unity-deep-linking/): the same setup step by step."
1691
+ },
1692
+ {
1693
+ "page": "Web",
1694
+ "group": "Platforms",
1695
+ "section": "Install",
1696
+ "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."
1698
+ },
1699
+ {
1700
+ "page": "Web",
1701
+ "group": "Platforms",
1702
+ "section": "Initialize",
1703
+ "url": "https://straitlink.in/docs/sdks/web/#initialize",
1704
+ "markdown": "There's no client to create: each call takes your publishable key and your link domain.\n\n```ts\nconst config = {\n publishableKey: 'st_pub_live_…', // Dashboard → Get started (never the secret key)\n endpoint: 'https://hilltop.strait.link', // your workspace's link domain\n};\n```"
1705
+ },
1706
+ {
1707
+ "page": "Web",
1708
+ "group": "Platforms",
1709
+ "section": "Handle deep links",
1710
+ "url": "https://straitlink.in/docs/sdks/web/#handle-deep-links",
1711
+ "markdown": "In a browser a tap needs no SDK: your short link sends the browser straight to the link's web destination, with its path and query, and your router reads them as usual. To send people on phones from your website into your app, use the [smart app banner](#smart-app-banner)."
1712
+ },
1713
+ {
1714
+ "page": "Web",
1715
+ "group": "Platforms",
1716
+ "section": "Deferred deep links",
1717
+ "url": "https://straitlink.in/docs/sdks/web/#deferred-deep-links",
1718
+ "markdown": "When someone taps a link, installs your web app or PWA, and opens it later, ask on the first visit whether this device recently tapped one of your links:\n\n```ts\nimport { resolveDeferredLink } from '@straitlink/web';\n\nconst result = await resolveDeferredLink(config);\n\nif (result.matched && result.longUrl) {\n const url = new URL(result.longUrl);\n window.location.assign(url.pathname + url.search); // or your router's navigate()\n}\n```\n\n`resolveDeferredLink` runs **at most once per browser** (it records one install on our side). It never throws: on any error it resolves to `{ matched: false, matchMethod: 'none' }`, so it's safe to await on startup. A match also has `linkId`, `matchMethod` and `clickId`."
1719
+ },
1720
+ {
1721
+ "page": "Web",
1722
+ "group": "Platforms",
1723
+ "section": "What it sends",
1724
+ "url": "https://straitlink.in/docs/sdks/web/#what-it-sends",
1725
+ "markdown": "Coarse device fields only: screen width, pixel ratio, two-letter language and time zone, posted to `/v1/match`. Our server adds the network it sees the request from and computes the match. The browser never sends an IP address, and the match code is never used to identify someone across apps or sites."
1726
+ },
1727
+ {
1728
+ "page": "Web",
1729
+ "group": "Platforms",
1730
+ "section": "Events",
1731
+ "url": "https://straitlink.in/docs/sdks/web/#events",
1732
+ "markdown": "From a website, send events with your publishable key. When `resolveDeferredLink` matched, its result also has `clickId`, the tap this visit came from; send it with the event so the revenue lands on that tap. The web SDK doesn't remember it for you, so keep it if you send events later.\n\n```ts\nawait fetch('https://strait.link/v1/event', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n publishableKey: 'st_pub_live_…',\n event: 'purchase',\n value: 49.99,\n currency: 'USD',\n clickId: result.clickId, // the matched tap, if any\n }),\n});\n```"
1733
+ },
1734
+ {
1735
+ "page": "Web",
1736
+ "group": "Platforms",
1737
+ "section": "Smart app banner",
1738
+ "url": "https://straitlink.in/docs/sdks/web/#smart-app-banner",
1739
+ "markdown": "To send visitors on phones from your website to your app, add the \"Open in app\" bar from the same package: `Strait.banner({ link, title })`. See [Smart app banner](https://straitlink.in/docs/smart-banner/)."
1740
+ },
1741
+ {
1742
+ "page": "Web",
1743
+ "group": "Platforms",
1744
+ "section": "API",
1745
+ "url": "https://straitlink.in/docs/sdks/web/#api",
1746
+ "markdown": "| Export | Purpose |\n|---|---|\n| `resolveDeferredLink(config, opts?)` | The one call you need on launch |\n| `banner(options)` / `Strait.banner(options)` | The [smart app banner](https://straitlink.in/docs/smart-banner/) for mobile web |\n| `computeSignature(inputs)` | The shared match signature (advanced / testing) |\n| `h32(s)` | The shared hash (advanced / testing) |"
1747
+ },
1748
+ {
1749
+ "page": "Web",
1750
+ "group": "Platforms",
1751
+ "section": "Common errors",
1752
+ "url": "https://straitlink.in/docs/sdks/web/#common-errors",
1753
+ "markdown": "- **`matched` is always `false` after the first visit.** Expected: the check runs once per browser and remembers that in `localStorage` (`strait_match_done`). Clear site data, or pass `{ force: true }` as the second argument while testing.\n- **`matched` is `false` on the first visit too.** The SDK hides errors, so open the browser's network tab and look at the `POST /v1/match` answer: `401` is a wrong or secret key (use the publishable key), `429` is the rate limit. Check `endpoint` is your link domain, `https://hilltop.strait.link`.\n- **The check runs on every visit.** The browser blocks `localStorage` (some private modes), so the SDK can't remember it ran. Each run may count another install."
1754
+ },
1755
+ {
1756
+ "page": "Smart app banner for mobile web",
1757
+ "group": "Platforms",
1758
+ "section": null,
1759
+ "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)."
1761
+ },
1762
+ {
1763
+ "page": "Smart app banner for mobile web",
1764
+ "group": "Platforms",
1765
+ "section": "1. Create a link for the banner",
1766
+ "url": "https://straitlink.in/docs/smart-banner/#1-create-a-link-for-the-banner",
1767
+ "markdown": "In Dashboard → Links, create a link whose destination is the screen the banner should open, for example `https://yourshop.example/app`. A link used only by the banner keeps its taps separate in Analytics.\n\nTo open the page the visitor is on (a product, an article), create one link per page through the [REST API](https://straitlink.in/docs/api/) and pass that page's link to the banner.\n\n**You should see:** your link in the list, for example `https://hilltop.strait.link/app`."
1768
+ },
1769
+ {
1770
+ "page": "Smart app banner for mobile web",
1771
+ "group": "Platforms",
1772
+ "section": "2. Install the web SDK",
1773
+ "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`."
1775
+ },
1776
+ {
1777
+ "page": "Smart app banner for mobile web",
1778
+ "group": "Platforms",
1779
+ "section": "3. Add the banner",
1780
+ "url": "https://straitlink.in/docs/smart-banner/#3-add-the-banner",
1781
+ "markdown": "Call it once when the page loads. Copy, then change the link, title and icon:\n\n```ts title=\"main.ts\"\nimport { Strait } from '@straitlink/web';\n\nStrait.banner({\n link: 'https://hilltop.strait.link/app', // your banner link from step 1\n title: 'Hilltop Shoes', // usually your app's name\n subtitle: 'Track your order in the app',\n icon: '/icons/app-192.png', // shown at 40×40\n theme: 'auto', // 'light' | 'dark' | 'auto'\n});\n```\n\nNo build step? `dist/banner.js` in the package has no imports, so you can copy it next to your pages and load it directly:\n\n```html title=\"index.html\"\n<script type=\"module\">\n import { banner } from '/js/banner.js';\n banner({ link: 'https://hilltop.strait.link/app', title: 'Hilltop Shoes', subtitle: 'Track your order in the app' });\n</script>\n```\n\n**You should see:** on a phone, a bar at the top of the page with your icon, title and an **Open in app** button. On a computer, nothing: the banner shows on phones and tablets only. To check the styling on a computer, add `force: true` for a moment."
1782
+ },
1783
+ {
1784
+ "page": "Smart app banner for mobile web",
1785
+ "group": "Platforms",
1786
+ "section": "4. Hide it inside your own app",
1787
+ "url": "https://straitlink.in/docs/smart-banner/#4-hide-it-inside-your-own-app",
1788
+ "markdown": "If your app shows your website in a WebView, tell the banner, so people already in the app don't see it. Add a token to your WebView's user agent and pass it, or pass your own check:\n\n```ts\nStrait.banner({\n link: 'https://hilltop.strait.link/app',\n title: 'Hilltop Shoes',\n appUserAgent: 'HilltopShoesApp/', // a token your WebView adds to its user agent\n // or: inApp: () => document.cookie.includes('in_app=1'),\n});\n```\n\nThe banner also hides on its own in WebViews that have a React Native WebView or `flutter_inappwebview` bridge. It still shows in in-app browsers such as Instagram's or WhatsApp's, which aren't your app.\n\n**You should see:** the bar on your site in a phone browser, and no bar on the same page inside your app."
1789
+ },
1790
+ {
1791
+ "page": "Smart app banner for mobile web",
1792
+ "group": "Platforms",
1793
+ "section": "5. Tap it and close it",
1794
+ "url": "https://straitlink.in/docs/smart-banner/#5-tap-it-and-close-it",
1795
+ "markdown": "On a phone with your app installed, tap **Open in app**. Then open the site again and tap the close button.\n\n**You should see:** your app opens on the link's screen, and the tap appears in Dashboard → Analytics. After you close the bar, it stays hidden on that browser for 30 days."
1796
+ },
1797
+ {
1798
+ "page": "Smart app banner for mobile web",
1799
+ "group": "Platforms",
1800
+ "section": "Options",
1801
+ "url": "https://straitlink.in/docs/smart-banner/#options",
1802
+ "markdown": "| Option | Default | What it does |\n|---|---|---|\n| `link` | (required) | Your Strait link. Opened as is. |\n| `title`, `subtitle` | (title required) | Text on the bar, shown as plain text. |\n| `icon` | none | Icon URL, shown at 40×40. |\n| `button` | `Open in app` | Button text. |\n| `theme` | `auto` | `light`, `dark`, or `auto` to follow the visitor's setting. |\n| `position` | `top` | `top` pushes the page down; `bottom` floats over it. |\n| `accent`, `accentText` | dark button | Button colour and its text colour, any CSS colour. |\n| `dismissDays` | `30` | Days it stays hidden after the visitor closes it. |\n| `inApp`, `appUserAgent` | none | Hide it inside your own app (step 4). |\n| `force` | `false` | Show it everywhere, even after a close. For previews only. |\n\n`banner()` returns `{ element, dismiss, remove }`, or `null` when it isn't shown."
1803
+ },
1804
+ {
1805
+ "page": "Smart app banner for mobile web",
1806
+ "group": "Platforms",
1807
+ "section": "What it sends",
1808
+ "url": "https://straitlink.in/docs/smart-banner/#what-it-sends",
1809
+ "markdown": "Nothing. The banner makes no requests and sets no cookies. It stores one value in the visitor's browser, `strait.banner.dismissed`, the time they closed it. The only thing recorded is the tap on your link, by Strait, the same as for any link. If the browser blocks storage, the banner still works and simply shows again on the next visit.\n\n:::note Safari's own banner\nSafari has a built-in banner (the `apple-itunes-app` meta tag). Use one or the other on iPhone, or visitors see two bars. Safari's banner appears only in Safari and doesn't go through your Strait link.\n:::"
1810
+ },
1811
+ {
1812
+ "page": "Smart app banner for mobile web",
1813
+ "group": "Platforms",
1814
+ "section": "Common errors",
1815
+ "url": "https://straitlink.in/docs/smart-banner/#common-errors",
1816
+ "markdown": "- **Nothing shows on your computer.** That's by design. Use a phone, or add `force: true` while you check the styling.\n- **Nothing shows on a phone either.** You closed it in the last 30 days on that browser. Clear the site's data, or use `force: true` once. A link that isn't `https://` or `http://` also stops it.\n- **The app opens on its home screen.** The link's destination is your home page, or the app doesn't route links yet. Check the destination in Dashboard → Links and set up [receiving links](https://straitlink.in/docs/sdks/react-native/receive-links/) in the app."
1817
+ },
1818
+ {
1819
+ "page": "Smart app banner for mobile web",
1820
+ "group": "Platforms",
1821
+ "section": "Next steps",
1822
+ "url": "https://straitlink.in/docs/smart-banner/#next-steps",
1823
+ "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
+ },
1825
+ {
1826
+ "page": "Store sheet (beta) — install another app without leaving yours",
1827
+ "group": "Platforms",
1828
+ "section": null,
1829
+ "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."
1831
+ },
1832
+ {
1833
+ "page": "Store sheet (beta) — install another app without leaving yours",
1834
+ "group": "Platforms",
1835
+ "section": "What it is, and where it works",
1836
+ "url": "https://straitlink.in/docs/store-sheet/#what-it-is-and-where-it-works",
1837
+ "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
+ },
1839
+ {
1840
+ "page": "Store sheet (beta) — install another app without leaving yours",
1841
+ "group": "Platforms",
1842
+ "section": "How it works",
1843
+ "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."
1845
+ },
1846
+ {
1847
+ "page": "Store sheet (beta) — install another app without leaving yours",
1848
+ "group": "Platforms",
1849
+ "section": "1. Check the installed app's details",
1850
+ "url": "https://straitlink.in/docs/store-sheet/#1-check-the-installed-apps-details",
1851
+ "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
+ },
1853
+ {
1854
+ "page": "Store sheet (beta) — install another app without leaving yours",
1855
+ "group": "Platforms",
1856
+ "section": "2. Android",
1857
+ "url": "https://straitlink.in/docs/store-sheet/#2-android",
1858
+ "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
+ },
1860
+ {
1861
+ "page": "Store sheet (beta) — install another app without leaving yours",
1862
+ "group": "Platforms",
1863
+ "section": "3. iPhone (beta)",
1864
+ "url": "https://straitlink.in/docs/store-sheet/#3-iphone-beta",
1865
+ "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
+ },
1867
+ {
1868
+ "page": "Store sheet (beta) — install another app without leaving yours",
1869
+ "group": "Platforms",
1870
+ "section": "4. React Native and Flutter",
1871
+ "url": "https://straitlink.in/docs/store-sheet/#4-react-native-and-flutter",
1872
+ "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
+ },
1874
+ {
1875
+ "page": "Store sheet (beta) — install another app without leaving yours",
1876
+ "group": "Platforms",
1877
+ "section": "5. Test it",
1878
+ "url": "https://straitlink.in/docs/store-sheet/#5-test-it",
1879
+ "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
+ },
1881
+ {
1882
+ "page": "Store sheet (beta) — install another app without leaving yours",
1883
+ "group": "Platforms",
1884
+ "section": "Limits",
1885
+ "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."
1887
+ },
1888
+ {
1889
+ "page": "Store sheet (beta) — install another app without leaving yours",
1890
+ "group": "Platforms",
1891
+ "section": "Common errors",
1892
+ "url": "https://straitlink.in/docs/store-sheet/#common-errors",
1893
+ "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
+ },
1895
+ {
1896
+ "page": "Store sheet (beta) — install another app without leaving yours",
1897
+ "group": "Platforms",
1898
+ "section": "Next steps",
1899
+ "url": "https://straitlink.in/docs/store-sheet/#next-steps",
1900
+ "markdown": "- [iPhone install matching](https://straitlink.in/docs/iphone-install-matching/): how the iPhone side finds the link after install.\n- [Analytics: how we count](https://straitlink.in/docs/analytics/): where store sheet taps show up."
1901
+ },
1902
+ {
1903
+ "page": "Short links",
1904
+ "group": "Links",
1905
+ "section": "Anatomy",
1906
+ "url": "https://straitlink.in/docs/short-links/#anatomy",
1907
+ "markdown": "```txt\nhttps://hilltop.strait.link/summer → https://shop.example.com/product/42\n └ link domain ┘ └slug┘ └──────── destination ────────┘\n```\n\n- **Slug**: letters, digits, `-` and `_`, up to 64 characters. A few words are reserved (`v1`, `healthz`, `debug`…). If a slug is already taken you get `409`.\n- **Destination** (`longUrl`): must be `https://`. Your app receives its path and query (`/product/42`), so use the same paths your app routes on.\n\n:::note No website?\nThe destination can be any `https://` URL on a domain you control, or your default web URL (Dashboard → Apps & stores) with the path you want, such as `https://yourapp.com/product/42`. Only the path and query reach your app. Desktop visitors go to the full URL, and so do people without the app if you chose \"always your website\", so make sure that page exists.\n:::"
1908
+ },
1909
+ {
1910
+ "page": "Short links",
1911
+ "group": "Links",
1912
+ "section": "What happens on a tap",
1913
+ "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. |"
1915
+ },
1916
+ {
1917
+ "page": "Short links",
1918
+ "group": "Links",
1919
+ "section": "How a tap is handled",
1920
+ "url": "https://straitlink.in/docs/short-links/#how-a-tap-is-handled",
1921
+ "markdown": "Every tap reaches Cloudflare first. What happens next depends on what the tap needs:\n\n- **Plain redirects** are decided at the Cloudflare location nearest the person, without a round trip to our servers. This covers desktop taps, Android browser taps (to your app, Google Play or your website) and iPhone taps that go directly to the website or the App Store. The decision is made by the same code our server runs, so the person lands in the same place either way.\n- **Everything else is served by our server**: pages (the iPhone **Get the app** page, in-app browser pages, password and expired pages), link-preview robots, and links with A/B destinations. A link close to its expiry time also goes to our server, so expiry is exact.\n\nBoth paths record the tap the same way: the same Analytics row, the same billing rules and the same `link.clicked` webhook. For a tap decided at the edge, the webhook is sent when our server receives the tap's record, a moment after the redirect. If that record can't be delivered right away, it's kept and sent again later."
1922
+ },
1923
+ {
1924
+ "page": "Short links",
1925
+ "group": "Links",
1926
+ "section": "When changes take effect",
1927
+ "url": "https://straitlink.in/docs/short-links/#when-changes-take-effect",
1928
+ "markdown": "When you change or deactivate a link, or change your workspace settings, taps follow the new version within 30 seconds. Changes made through the API take effect right away on our server and at the Cloudflare location that carried the request; other locations, and changes made in the dashboard, can take up to 30 seconds."
1929
+ },
1930
+ {
1931
+ "page": "Short links",
1932
+ "group": "Links",
1933
+ "section": "Link controls",
1934
+ "url": "https://straitlink.in/docs/short-links/#link-controls",
1935
+ "markdown": "Each link can also have:\n\n- **Per-platform fallbacks**: `iosFallbackUrl`, `androidFallbackUrl`, `desktopUrl` (all https).\n- **Social preview**: title, description and image for chat-app previews.\n- **Campaign, tags, folders and UTM parameters** to organise links and analytics.\n- **Expiry**: after it, the link stops opening its destination. You choose where taps go instead (see below).\n- **Password**: people must enter it before being redirected. Previews still work.\n- **A/B destinations**: split taps across several destinations. Analytics shows which variant each tap got.\n- **QR code**: download one for any link from the dashboard. [Branded QR codes](https://straitlink.in/docs/qr-codes/) add your colours, your app icon and an API.\n- **Scheduled go-live**: share a link before launch and choose where early taps go. [Guide](https://straitlink.in/docs/scheduled-go-live/).\n- **Country rules**: send people in chosen countries to their own web page. [Guide](https://straitlink.in/docs/country-rules/)."
1936
+ },
1937
+ {
1938
+ "page": "Short links",
1939
+ "group": "Links",
1940
+ "section": "When a link expires",
1941
+ "url": "https://straitlink.in/docs/short-links/#when-a-link-expires",
1942
+ "markdown": "After its expiry time a link no longer opens its destination. You decide where people go instead. Set the default for every link in **Settings → When a link expires**, and change it for one link in its edit panel (or with the API fields below). A link with no setting of its own follows the workspace default.\n\n| Option | What people see |\n|---|---|\n| **Show an 'expired' page** (default) | A short \"This link has expired\" page with your app's name and a link to your website. HTTP 410. |\n| **Send to the app store** | Google Play on Android, the App Store on iPhone, using your app configuration. On Android the install referrer carries the tap, the same as any store hand-off. Computers, and phones whose store isn't set up, go to your expired-link web page if you set one, otherwise they see the expired page. |\n| **Send to a web page** | Everyone goes to the https page you choose. If it's missing, or fails our phishing and malware check, people see the expired page instead. |\n\nThe store and web page options answer with a plain redirect (HTTP 302). Whichever option you pick, a tap on an expired link is recorded with **Sent to: expired**, plus where it went, and is never billed or sent as a `link.clicked` webhook. The dashboard Playground and `GET /v1/simulate` show the same decision as a real tap.\n\nWith the API, `POST /v1/links` and `PATCH /v1/links/{slug}` accept:\n\n- `expiresAt`: epoch milliseconds or an ISO 8601 date-time (`null` in a PATCH removes the expiry).\n- `expiredAction`: `page`, `store` or `url` (`null` in a PATCH goes back to the workspace default).\n- `expiredUrl`: an https page. Required with `url`. With `store`, it's where computers go.\n\n```bash\ncurl -X PATCH https://strait.link/v1/links/summer \\\n -H \"Authorization: Bearer $STRAIT_SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"expiredAction\":\"url\",\"expiredUrl\":\"https://yourco.com/offers\"}'\n```"
1943
+ },
1944
+ {
1945
+ "page": "Short links",
1946
+ "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."
1950
+ },
1951
+ {
1952
+ "page": "Short links",
1953
+ "group": "Links",
1954
+ "section": "Links that break the rules",
1955
+ "url": "https://straitlink.in/docs/short-links/#links-that-break-the-rules",
1956
+ "markdown": "Links used for phishing, malware or spam are disabled under the [Acceptable Use Policy](https://straitlink.in/legal/acceptable-use/). A disabled link shows a warning page instead of redirecting."
1957
+ },
1958
+ {
1959
+ "page": "Link domains",
1960
+ "group": "Links",
1961
+ "section": "Why your own subdomain",
1962
+ "url": "https://straitlink.in/docs/link-domains/#why-your-own-subdomain",
1963
+ "markdown": "Android App Links and iPhone Universal Links are verified **per domain**. The phone downloads a small file from the link's domain that says which app may open its links:\n\n- `https://hilltop.strait.link/.well-known/assetlinks.json` (Android)\n- `https://hilltop.strait.link/.well-known/apple-app-site-association` (iPhone)\n\nIf every customer shared one domain, that file could only name one app, and everyone else's links would open the browser. So each workspace gets its own subdomain, and we serve your app's files from it, built from the details in **Dashboard → Apps & stores**."
1964
+ },
1965
+ {
1966
+ "page": "Link domains",
1967
+ "group": "Links",
1968
+ "section": "Slugs belong to your workspace",
1969
+ "url": "https://straitlink.in/docs/link-domains/#slugs-belong-to-your-workspace",
1970
+ "markdown": "A slug is unique within your workspace, not across Strait. Two workspaces can both have `/summer`: `https://hilltop.strait.link/summer` and `https://oaklane.strait.link/summer` are different links that open different apps.\n\nThat's why you should **always share your handle URL**, never the bare `https://strait.link/summer`. The bare `strait.link` root only answers a slug that no other workspace uses, so links shared before workspaces had their own domains keep working. Once a second workspace creates the same slug, the bare address finds nothing. The API returns the right address for every link as `shortUrl`, and the dashboard copies it for you."
1971
+ },
1972
+ {
1973
+ "page": "Link domains",
1974
+ "group": "Links",
1975
+ "section": "Your handle",
1976
+ "url": "https://straitlink.in/docs/link-domains/#your-handle",
1977
+ "markdown": "Your handle comes from your workspace name when you sign up (for example \"Hilltop Shoes\" becomes `hilltop-shoes`). Handles are 3 to 30 characters: lowercase letters, digits and hyphens. Some words, like `www`, `api` and `status`, are reserved for our own use.\n\n:::warn Choose your handle before your first link\nThe workspace owner can rename the handle in **Dashboard → Get started** (or **Settings**) until the workspace creates its first link. After that it's fixed, because links you've shared and the App Links / Universal Links setup in your app depend on it.\n:::\n\nUse your link domain in three places:\n\n1. **The SDK `endpoint`**: `https://hilltop.strait.link`\n2. **Android**: the `android:host` of your App Links intent filter\n3. **iPhone**: the Associated Domains entry `applinks:hilltop.strait.link`"
1978
+ },
1979
+ {
1980
+ "page": "Link domains",
1981
+ "group": "Links",
1982
+ "section": "Custom domains",
1983
+ "url": "https://straitlink.in/docs/link-domains/#custom-domains",
1984
+ "markdown": "Custom domains are coming soon, and founding apps get them first. Today every workspace gets `<handle>.strait.link`, with HTTPS, on every plan. How they'll work, on Scale and Enterprise: [Custom domains](https://straitlink.in/docs/custom-domains/).\n\n:::note\nAfter changing your app's signing keys or team ID, phones and Google's verifier re-read the files on their own schedule. We serve them with a short cache (10 minutes), but Android can take longer to re-verify an installed app. Reinstalling the app forces a fresh check.\n:::"
1985
+ },
1986
+ {
1987
+ "page": "Custom domains",
1988
+ "group": "Links",
1989
+ "section": null,
1990
+ "url": "https://straitlink.in/docs/custom-domains/",
1991
+ "markdown": ":::note Coming soon\nCustom domains are coming soon, and founding apps get them first. This guide shows how they'll work. Until they're switched on, Settings shows \"not switched on yet\" and the API answers `enabled: false` or `503 domains_not_enabled`.\n:::"
1992
+ },
1993
+ {
1994
+ "page": "Custom domains",
1995
+ "group": "Links",
1996
+ "section": "Before you start",
1997
+ "url": "https://straitlink.in/docs/custom-domains/#before-you-start",
1998
+ "markdown": "- **Plan:** custom domains are part of **Scale** and **Enterprise**. On other plans the API answers `402 plan_required`.\n- **How many:** up to **5** per workspace.\n- **Which name:** a **subdomain** you don't use for anything else, like `links.yourcompany.com` or `go.yourcompany.com`. A bare domain (`yourcompany.com`) can't be used, because a CNAME can't sit on it. Our own domains and IP addresses are refused.\n- **Who:** owners, admins and members can add and remove domains; viewers can see them.\n\nYour links keep working on `<handle>.strait.link` the whole time. A custom domain is an extra name for the same links."
1999
+ },
2000
+ {
2001
+ "page": "Custom domains",
2002
+ "group": "Links",
2003
+ "section": "1. Add the domain",
2004
+ "url": "https://straitlink.in/docs/custom-domains/#1-add-the-domain",
2005
+ "markdown": "In **Dashboard → Settings → Custom domains**, type the subdomain and press **Add domain**. Or from your server, with the secret key:\n\n```bash\ncurl -X POST https://strait.link/v1/domains \\\n -H \"Authorization: Bearer st_live_…\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"hostname\":\"links.yourcompany.com\"}'\n```\n\nThe answer lists the exact records to add. Reference: [Add a custom domain](https://straitlink.in/docs/api/add-domain/)."
2006
+ },
2007
+ {
2008
+ "page": "Custom domains",
2009
+ "group": "Links",
2010
+ "section": "2. Add three DNS records",
2011
+ "url": "https://straitlink.in/docs/custom-domains/#2-add-three-dns-records",
2012
+ "markdown": "At your DNS provider, add the three records the dashboard shows, copied exactly (each has a copy button):\n\n| Type | Name | Value | What it's for |\n|---|---|---|---|\n| `CNAME` | `links.yourcompany.com` | `fallback.strait.link` | Sends your links to Strait |\n| `TXT` | `_cf-custom-hostname.links.yourcompany.com` | a value made for your domain | Proves you own the domain |\n| `TXT` | `_acme-challenge.links.yourcompany.com` | a value made for your domain | Lets us issue its HTTPS certificate |\n\nBoth TXT values are new each time a domain is added, so always copy them from the dashboard or the API answer. If your DNS is on Cloudflare, set the CNAME to **DNS only** (grey cloud)."
2013
+ },
2014
+ {
2015
+ "page": "Custom domains",
2016
+ "group": "Links",
2017
+ "section": "3. Wait for Live",
2018
+ "url": "https://straitlink.in/docs/custom-domains/#3-wait-for-live",
2019
+ "markdown": "The dashboard checks the domain every 15 seconds while the page is open. Its status moves on by itself:\n\n| Status | What it means | What to do |\n|---|---|---|\n| **Waiting for DNS** | We can't see the records yet | Add them. DNS changes can take a few minutes to an hour to show up |\n| **Issuing certificate** | DNS is right; the HTTPS certificate is being issued | Wait, usually a few minutes |\n| **Live** | Your links work on this domain, with HTTPS | Nothing |\n| **Needs attention** | Something is wrong; the card shows the message from our certificate provider | Check every record matches exactly, then wait a few minutes |\n\nA domain goes **Live** only when the certificate is issued **and** the ownership TXT record is in your DNS. A CNAME alone is never enough, so a domain someone else once pointed at us can't be claimed without its owner's DNS. In the API, `status` is `pending_dns`, `issuing_certificate`, `live` or `problem` (plus `not_created` and `unknown`); see [List custom domains](https://straitlink.in/docs/api/list-domains/)."
2020
+ },
2021
+ {
2022
+ "page": "Custom domains",
2023
+ "group": "Links",
2024
+ "section": "4. Open your app from the new domain",
2025
+ "url": "https://straitlink.in/docs/custom-domains/#4-open-your-app-from-the-new-domain",
2026
+ "markdown": "Once the domain is live, we serve your app's link files on it too (`/.well-known/assetlinks.json` and `/.well-known/apple-app-site-association`). For links on it to open your app instead of the browser, add the domain to your next app build:\n\n1. **Android:** another `<data android:host=\"links.yourcompany.com\" />` in your App Links intent filter (the one with `android:autoVerify=\"true\"`).\n2. **iPhone:** another Associated Domains entry, `applinks:links.yourcompany.com`.\n3. **SDK:** add `links.yourcompany.com` to `linkHosts` in the SDK config, so the SDK treats links on it as yours.\n\nOnce a domain is live, `shortUrl` in API answers uses it, and the dashboard copies links on it. Links you shared before keep working on `<handle>.strait.link`."
2027
+ },
2028
+ {
2029
+ "page": "Custom domains",
2030
+ "group": "Links",
2031
+ "section": "Remove a domain",
2032
+ "url": "https://straitlink.in/docs/custom-domains/#remove-a-domain",
2033
+ "markdown": "Press **Remove** on the domain's card, or call [Remove a custom domain](https://straitlink.in/docs/api/remove-domain/) (`DELETE /v1/domains/{hostname}`). Links shared on that domain stop working right away; the same links keep working on `<handle>.strait.link`.\n\nThen **delete its DNS records** at your DNS provider: the CNAME and both TXT records. We stop serving the domain either way, but a CNAME left pointing at us is a record you don't need.\n\nClosing a workspace also removes its custom domains from our certificate provider. The names stay reserved for that workspace until it's deleted for good, 30 days later."
2034
+ },
2035
+ {
2036
+ "page": "Custom domains",
2037
+ "group": "Links",
2038
+ "section": "Limits",
2039
+ "url": "https://straitlink.in/docs/custom-domains/#limits",
2040
+ "markdown": "Status reads are limited to 20 at once, then 1 every 2 seconds, per workspace. Adds and removes together: 10, then 1 every 6 minutes. Details: [Rate limits](https://straitlink.in/docs/rate-limits/)."
2041
+ },
2042
+ {
2043
+ "page": "Branded QR codes",
2044
+ "group": "Links",
2045
+ "section": null,
2046
+ "url": "https://straitlink.in/docs/qr-codes/",
2047
+ "markdown": "A QR code is the link drawn as squares. Strait draws every code from one workspace style, so a code you download from the dashboard and one your server makes through the API look the same. Scans count as the **qr** channel in Analytics unless you turn that off."
2048
+ },
2049
+ {
2050
+ "page": "Branded QR codes",
2051
+ "group": "Links",
2052
+ "section": "1. Set your workspace's QR style",
2053
+ "url": "https://straitlink.in/docs/qr-codes/#1-set-your-workspaces-qr-style",
2054
+ "markdown": "Open **Dashboard → Settings → QR codes** and choose:\n\n- **Code colour**: the squares. Empty means black.\n- **Background**: behind the squares. Empty means white.\n- **Put my app icon in the middle**: uses the app icon from **How your link pages look**. The code then uses the highest error correction level (H), so it still scans with the middle covered.\n\nThe colours must pass one rule: **the code must be darker than its background, with a contrast of at least 3:1**. Phone cameras look for dark squares on a light background, so light-on-dark codes and pale colour pairs are refused, in the dashboard and in the API. The form shows the contrast as you type. Then press **Save QR style**. Owners, admins and members can change it; viewers can only look.\n\n**You should see:** the preview in your colours, a line under the colour fields like \"Contrast 21.0:1: phone cameras will read it\", and \"Saved ✓\" after you save."
2055
+ },
2056
+ {
2057
+ "page": "Branded QR codes",
2058
+ "group": "Links",
2059
+ "section": "2. Download a code from the dashboard",
2060
+ "url": "https://straitlink.in/docs/qr-codes/#2-download-a-code-from-the-dashboard",
2061
+ "markdown": "Go to **Dashboard → Links** and press **QR** on any link. The dialog shows the code in your style and the exact URL it opens. Keep **Count scans as the qr channel in Analytics** ticked to add `utm_source=qr` to the code, then pick a download:\n\n| Button | You get |\n|---|---|\n| **Download PNG** | 1024 × 1024 px, for screens and documents |\n| **PNG for print** | 2048 × 2048 px |\n| **Download SVG** | Vector, sharp at any print size |\n\n**You should see:** a file named after the link in your downloads (`summer-qr.png`, `summer-qr-2048.png` or `summer-qr.svg`), and the code opening the link when you scan it with a phone."
2062
+ },
2063
+ {
2064
+ "page": "Branded QR codes",
2065
+ "group": "Links",
2066
+ "section": "3. Make codes from your server",
2067
+ "url": "https://straitlink.in/docs/qr-codes/#3-make-codes-from-your-server",
2068
+ "markdown": "Call [`GET /v1/links/{slug}/qr`](https://straitlink.in/docs/api/get-link-qr/) with a secret key. It answers with the image itself, in the workspace style unless you override it for this one code:\n\n| Query parameter | Values | Default |\n|---|---|---|\n| `format` | `png` or `svg` | `png` |\n| `size` | 128 to 2048 px | 1024 |\n| `margin` | 0 to 16 modules of quiet zone | 2 |\n| `color`, `background` | hex, like `1f2937` | the workspace style |\n| `logo` | `true` or `false` | the workspace style |\n| `track` | `true` adds `utm_source=qr` | `true` |\n\n```bash\ncurl \"https://strait.link/v1/links/summer/qr?format=svg&color=1f2937\" \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n --fail -o summer-qr.svg\n```\n\n```js title=\"Node 18+\"\nimport { writeFile } from 'node:fs/promises';\n\nconst res = await fetch('https://strait.link/v1/links/summer/qr?format=png&size=2048', {\n headers: { Authorization: `Bearer ${process.env.SECRET_KEY}` },\n});\nif (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);\nawait writeFile('summer-qr.png', Buffer.from(await res.arrayBuffer()));\nconsole.log(res.headers.get('x-strait-qr-url'), res.headers.get('x-strait-qr-logo'));\n```\n\nTwo response headers tell you what you got: `X-Strait-Qr-Url` is the exact URL in the code, and `X-Strait-Qr-Logo` says whether the icon went in (`embedded`, `off`, `no_icon`, `unavailable` or `png_needs_png_icon`). Python, PHP, Java and Go versions are on the [endpoint page](https://straitlink.in/docs/api/get-link-qr/). Write the colours without `#`, or as `%23` (a raw `#` starts the URL's fragment).\n\n**You should see:** `summer-qr.svg` on disk, and `X-Strait-Qr-Url: https://hilltop.strait.link/summer?utm_source=qr`."
2069
+ },
2070
+ {
2071
+ "page": "Branded QR codes",
2072
+ "group": "Links",
2073
+ "section": "4. Check scans in Analytics",
2074
+ "url": "https://straitlink.in/docs/qr-codes/#4-check-scans-in-analytics",
2075
+ "markdown": "Scan a downloaded code with a phone, then open **Dashboard → Analytics**.\n\n**You should see:** the tap under the **qr** channel, with where it went (app, store or website), like any other tap."
2076
+ },
2077
+ {
2078
+ "page": "Branded QR codes",
2079
+ "group": "Links",
2080
+ "section": "Common errors",
2081
+ "url": "https://straitlink.in/docs/qr-codes/#common-errors",
2082
+ "markdown": "- **`400` \"the QR colours are too close to scan reliably (contrast 2.1:1, needs 3:1)\"**. Darken the code colour or lighten the background until the contrast is 3:1 or more. Plain black on white is always fine.\n- **`400` \"the QR colour must be darker than its background\"**. Light codes on dark backgrounds are refused because many phone cameras miss them. Swap the two colours.\n- **`X-Strait-Qr-Logo: png_needs_png_icon`**. Your app icon is a JPEG or WebP, and PNG codes can only embed a PNG icon. Ask for `format=svg`, or upload a PNG icon in Settings."
2083
+ },
2084
+ {
2085
+ "page": "Branded QR codes",
2086
+ "group": "Links",
2087
+ "section": "Next steps",
2088
+ "url": "https://straitlink.in/docs/qr-codes/#next-steps",
2089
+ "markdown": "- [Analytics: how we count](https://straitlink.in/docs/analytics/): what a tap is, and how the qr channel is decided.\n- [Get a branded QR code (API reference)](https://straitlink.in/docs/api/get-link-qr/): every parameter, the rate limit and all six languages."
2090
+ },
2091
+ {
2092
+ "page": "Scheduled go-live",
2093
+ "group": "Links",
2094
+ "section": null,
2095
+ "url": "https://straitlink.in/docs/scheduled-go-live/",
2096
+ "markdown": "Print the poster, schedule the WhatsApp broadcast and send the email before the sale starts. Until the go-live time, the link isn't live yet, and you choose where taps go. From that moment it opens its destination like any other link."
2097
+ },
2098
+ {
2099
+ "page": "Scheduled go-live",
2100
+ "group": "Links",
2101
+ "section": "1. Set the go-live time",
2102
+ "url": "https://straitlink.in/docs/scheduled-go-live/#1-set-the-go-live-time",
2103
+ "markdown": "In **Dashboard → Links**, open the link's edit panel and fill in **Goes live**. The time is in your own time zone, which the label shows. Leave it blank for a link that is live now.\n\n**You should see:** a **Goes live …** chip in the link's edit panel, with the date, time and time zone."
2104
+ },
2105
+ {
2106
+ "page": "Scheduled go-live",
2107
+ "group": "Links",
2108
+ "section": "2. Choose where taps go before then",
2109
+ "url": "https://straitlink.in/docs/scheduled-go-live/#2-choose-where-taps-go-before-then",
2110
+ "markdown": "Once a go-live time is set, the panel asks **Before it goes live, send people to**:\n\n| Option | What people see |\n|---|---|\n| **Show a 'coming soon' page** (default) | A short page saying the link isn't live yet and when it opens, with your app's name and a link to your website. |\n| **Send to the app store** | Google Play on Android, the App Store on iPhone, from your app configuration, so people can install ahead of launch. Computers, and phones whose store isn't set up, go to the web page you give here, else the coming-soon page. |\n| **Send to a web page** | Everyone goes to the https page you choose until the link goes live. |\n\nSave the link.\n\n**You should see:** your choice saved on the link. In the dashboard Playground, each device shows **Not live yet: gets the link's before-launch destination**."
2111
+ },
2112
+ {
2113
+ "page": "Scheduled go-live",
2114
+ "group": "Links",
2115
+ "section": "3. Or do it with the API",
2116
+ "url": "https://straitlink.in/docs/scheduled-go-live/#3-or-do-it-with-the-api",
2117
+ "markdown": "[`POST /v1/links`](https://straitlink.in/docs/api/create-link/), [`POST /v1/links/batch`](https://straitlink.in/docs/api/batch-create-links/) and [`PATCH /v1/links/{slug}`](https://straitlink.in/docs/api/update-link/) accept:\n\n- `startsAt`: epoch milliseconds or an ISO 8601 date-time. It must be before `expiresAt` if the link has one. In a PATCH, `null` makes the link live now.\n- `notLiveAction`: `page`, `store` or `url`. Leave it out for the coming-soon page.\n- `notLiveUrl`: an https page. Required with `url`; with `store`, it's where computers go. It passes the same phishing and malware check as every destination.\n\n```bash\ncurl -X POST https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"slug\": \"diwali-sale\",\n \"longUrl\": \"https://shop.example.com/sale/diwali\",\n \"startsAt\": \"2026-10-20T00:00:00+05:30\",\n \"notLiveAction\": \"store\"\n }'\n```\n\n**You should see:** `201` with the link, including `startsAt` as epoch milliseconds and `notLiveAction: \"store\"`."
2118
+ },
2119
+ {
2120
+ "page": "Scheduled go-live",
2121
+ "group": "Links",
2122
+ "section": "4. Check it before and after",
2123
+ "url": "https://straitlink.in/docs/scheduled-go-live/#4-check-it-before-and-after",
2124
+ "markdown": "Ask [`GET /v1/simulate`](https://straitlink.in/docs/api/simulate-tap/) what a tap would do. It records nothing:\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https://hilltop.strait.link/diwali-sale&ua=android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** `\"decision\": \"not_live\"` and `\"notLiveTo\": \"store\"` before the go-live time, and the link's normal decision after it."
2125
+ },
2126
+ {
2127
+ "page": "Scheduled go-live",
2128
+ "group": "Links",
2129
+ "section": "What counts before go-live",
2130
+ "url": "https://straitlink.in/docs/scheduled-go-live/#what-counts-before-go-live",
2131
+ "markdown": "Taps before the go-live time **are not counted**: they aren't recorded in Analytics, never count toward your plan's taps, and send no `link.clicked` webhook. Link previews still work, so a link shared ahead of launch unfurls in WhatsApp and other chat apps with its title and image. After the go-live time, every tap is recorded and billed as usual."
2132
+ },
2133
+ {
2134
+ "page": "Scheduled go-live",
2135
+ "group": "Links",
2136
+ "section": "Common errors",
2137
+ "url": "https://straitlink.in/docs/scheduled-go-live/#common-errors",
2138
+ "markdown": "- **`400` \"startsAt must be before expiresAt\"**. The link would expire before it goes live. Move one of the two times.\n- **`400` \"notLiveAction \\\"url\\\" needs notLiveUrl (an https URL)\"**. Send `notLiveUrl` with `url`, or pick `page`.\n- **`400` \"notLiveUrl needs notLiveAction \\\"url\\\" or \\\"store\\\"\"**. The coming-soon page has no web page; drop `notLiveUrl` or change the action."
2139
+ },
2140
+ {
2141
+ "page": "Scheduled go-live",
2142
+ "group": "Links",
2143
+ "section": "Next steps",
2144
+ "url": "https://straitlink.in/docs/scheduled-go-live/#next-steps",
2145
+ "markdown": "- [Country rules](https://straitlink.in/docs/country-rules/): send people in some countries to a different web page.\n- [Short links](https://straitlink.in/docs/short-links/#when-a-link-expires): the other end of a link's life, where taps go after it expires."
2146
+ },
2147
+ {
2148
+ "page": "Country rules",
2149
+ "group": "Links",
2150
+ "section": null,
2151
+ "url": "https://straitlink.in/docs/country-rules/",
2152
+ "markdown": "One link on a global campaign can send India to your Indian store page and Bangladesh to its own page, without separate links.\n\nCountry rules **only change web and fallback destinations**: where someone lands in a browser. That is the computer destination, the per-platform fallbacks, the link's destination when it opens as a web page, and your workspace fallback. Opening your app on the right screen, and sending people to Google Play or the App Store, are unchanged, so deep links and deferred deep links behave exactly as before."
2153
+ },
2154
+ {
2155
+ "page": "Country rules",
2156
+ "group": "Links",
2157
+ "section": "1. Add a rule",
2158
+ "url": "https://straitlink.in/docs/country-rules/#1-add-a-rule",
2159
+ "markdown": "In **Dashboard → Links**, open the link's edit panel, find **Country rules** and press **Add a country rule**. Fill in:\n\n- **Countries**: 2-letter country codes separated by commas, like `IN, BD`.\n- **Web page**: the https page for people in those countries.\n\nA link can have up to 5 rules, each with up to 50 countries. The first rule that lists the visitor's country wins. Save the link.\n\n**You should see:** the rule saved on the link, and \"Rule 1\" with your countries and page when you open the panel again."
2160
+ },
2161
+ {
2162
+ "page": "Country rules",
2163
+ "group": "Links",
2164
+ "section": "2. Or do it with the API",
2165
+ "url": "https://straitlink.in/docs/country-rules/#2-or-do-it-with-the-api",
2166
+ "markdown": "[`POST /v1/links`](https://straitlink.in/docs/api/create-link/), [`POST /v1/links/batch`](https://straitlink.in/docs/api/batch-create-links/) and [`PATCH /v1/links/{slug}`](https://straitlink.in/docs/api/update-link/) accept `countryRules`, a list of `{ countries, url }`. A PATCH replaces all of a link's rules; `null` or `[]` removes them.\n\n```bash\ncurl -X PATCH https://strait.link/v1/links/summer \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"countryRules\": [\n { \"countries\": [\"IN\"], \"url\": \"https://shop.example.com/in/summer\" },\n { \"countries\": [\"BD\", \"NP\"], \"url\": \"https://shop.example.com/south-asia/summer\" }\n ]\n }'\n```\n\nCountry codes are trimmed and upper-cased for you. Every rule's page passes the same phishing and malware check as the link's destination.\n\n**You should see:** `200` with the link, its `countryRules` listing both rules."
2167
+ },
2168
+ {
2169
+ "page": "Country rules",
2170
+ "group": "Links",
2171
+ "section": "3. Test it as a visitor from another country",
2172
+ "url": "https://straitlink.in/docs/country-rules/#3-test-it-as-a-visitor-from-another-country",
2173
+ "markdown": "In the dashboard **Playground**, type a code in **Visitor's country (optional)**. Or ask [`GET /v1/simulate`](https://straitlink.in/docs/api/simulate-tap/), which records nothing:\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https://hilltop.strait.link/summer&ua=desktop&country=IN\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** `\"countryRule\": true` and `location` set to your India page for `IN`, and the usual destination for a country without a rule. On a phone with your app installed, the decision is still to open the app."
2174
+ },
2175
+ {
2176
+ "page": "Country rules",
2177
+ "group": "Links",
2178
+ "section": "Where the country comes from",
2179
+ "url": "https://straitlink.in/docs/country-rules/#where-the-country-comes-from",
2180
+ "markdown": "The country is the one Cloudflare gives the request, from the visitor's network address. It is used to make this one decision and is never stored with the tap. When the country can't be told, no rule applies and the usual destination is used."
2181
+ },
2182
+ {
2183
+ "page": "Country rules",
2184
+ "group": "Links",
2185
+ "section": "Common errors",
2186
+ "url": "https://straitlink.in/docs/country-rules/#common-errors",
2187
+ "markdown": "- **`400` \"countryRules[0].countries: \\\"IND\\\" is not a 2-letter country code\"**. Use ISO 3166-1 two-letter codes: `IN`, not `IND` or `India`.\n- **`400` \"countryRules allows at most 5 rules\"**. Combine countries that share a page into one rule.\n- **A rule doesn't change where the app opens.** That is by design: rules only replace web and fallback destinations. To send a country to a different screen in the app, use a separate link."
2188
+ },
2189
+ {
2190
+ "page": "Country rules",
2191
+ "group": "Links",
2192
+ "section": "Next steps",
2193
+ "url": "https://straitlink.in/docs/country-rules/#next-steps",
2194
+ "markdown": "- [Scheduled go-live](https://straitlink.in/docs/scheduled-go-live/): share a link before launch and choose where early taps go.\n- [Simulate a tap (API reference)](https://straitlink.in/docs/api/simulate-tap/): every decision the playground can show."
2195
+ },
2196
+ {
2197
+ "page": "Deferred deep linking",
2198
+ "group": "Concepts",
2199
+ "section": "What your app sees",
2200
+ "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/)."
2202
+ },
2203
+ {
2204
+ "page": "Deferred deep linking",
2205
+ "group": "Concepts",
2206
+ "section": "Android: the Play Install Referrer (exact)",
2207
+ "url": "https://straitlink.in/docs/deferred-deep-linking/#android-the-play-install-referrer-exact",
2208
+ "markdown": "When someone without your app taps a link on Android, we send them to Google Play with the link attached to the install:\n\n```txt\nhttps://play.google.com/store/apps/details?id=com.yourco.app&referrer=strait_link%3D<link id>%26strait_click%3D<tap id>\n```\n\nGoogle Play keeps that referrer through the install. On first open, the SDK reads it, asks our server for the link (`/v1/referrer`), and routes your app there. It's **deterministic**: no guessing, and the install is joined to the exact tap.\n\nTo test it you need a build installed **from Google Play**. An internal testing track works. Sideloaded builds have no referrer.\n\nIf there's no referrer link (the app was found in a store search, for example), the SDK still checks in once so the install is counted in your match rate. Don't expect a match in that case on Android."
2209
+ },
2210
+ {
2211
+ "page": "Deferred deep linking",
2212
+ "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))."
2216
+ },
2217
+ {
2218
+ "page": "Deferred deep linking",
2219
+ "group": "Concepts",
2220
+ "section": "Match methods",
2221
+ "url": "https://straitlink.in/docs/deferred-deep-linking/#match-methods",
2222
+ "markdown": "Every install in Analytics shows how it was matched:\n\n| Method | Meaning |\n|---|---|\n| `install_referrer` | Exact, from the Google Play referrer |\n| `exact_ext` | iPhone signature matched on every input |\n| `exact_core` | iPhone signature matched on the core inputs |\n| `scored` | iPhone signals scored against recent taps; used only in workspaces where Strait has turned scored matching on |\n| `clipboard` | iPhone clipboard boost: the one-time handoff link from the tap page (exact) |\n| `none` | No match: organic install, or the match window passed |"
2223
+ },
2224
+ {
2225
+ "page": "Deferred deep linking",
2226
+ "group": "Concepts",
2227
+ "section": "Once per install",
2228
+ "url": "https://straitlink.in/docs/deferred-deep-linking/#once-per-install",
2229
+ "markdown": "The deferred check runs once per install. It's skipped (and marked done) when the first launch was itself opened by a link, so a direct open is never followed by a stale deferred jump. If the phone is offline on first launch, the check runs again next launch."
2230
+ },
2231
+ {
2232
+ "page": "App Links & Universal Links",
2233
+ "group": "Concepts",
2234
+ "section": "What we host",
2235
+ "url": "https://straitlink.in/docs/app-links/#what-we-host",
2236
+ "markdown": "From the app details in **Dashboard → Settings**, your link domain serves:\n\n| File | Built from | Used by |\n|---|---|---|\n| `/.well-known/assetlinks.json` | Package name + every SHA-256 fingerprint you list | Android, to verify App Links |\n| `/.well-known/apple-app-site-association` | Apple Team ID + bundle ID | iPhone, to verify Universal Links |\n\nEach file returns `404` until the matching fields are filled in. Check yours:\n\n```bash\ncurl https://hilltop.strait.link/.well-known/assetlinks.json\ncurl https://hilltop.strait.link/.well-known/apple-app-site-association\n```\n\nOr paste your link domain into the [App Links checker](https://straitlink.in/tools/app-links-checker/), which checks both files and explains any problem."
2237
+ },
2238
+ {
2239
+ "page": "App Links & Universal Links",
2240
+ "group": "Concepts",
2241
+ "section": "Android",
2242
+ "url": "https://straitlink.in/docs/app-links/#android",
2243
+ "markdown": "1. Add an intent filter with `android:autoVerify=\"true\"` for `https://hilltop.strait.link` ([example](https://straitlink.in/docs/production-checklist/#2-verify-app-links-on-android)).\n2. List **every** certificate fingerprint your app is signed with in **Dashboard → Apps & stores**: debug, upload, and the Play App Signing key from Play Console. A Play-installed app is signed by Google's key, not yours. Fingerprints are checked when you save: each one is stored as 32 uppercase hex pairs joined by colons (`FA:C6:17:…`), duplicates are dropped, and anything that isn't a SHA-256 fingerprint is refused. The package name must look like a Java package (`com.yourco.app`), and the website fallback must be `https://`.\n3. Install the app. Android checks the file at install time.\n\nCheck verification on a connected phone:\n\n```bash\nadb shell pm get-app-links com.yourco.app\n# Look for: hilltop.strait.link: verified\n```\n\nIf it says `1024` or `none`, Android couldn't verify. See [Troubleshooting](https://straitlink.in/docs/troubleshooting/#the-link-opens-the-browser-instead-of-the-app)."
2244
+ },
2245
+ {
2246
+ "page": "App Links & Universal Links",
2247
+ "group": "Concepts",
2248
+ "section": "iPhone (beta)",
2249
+ "url": "https://straitlink.in/docs/app-links/#iphone-beta",
2250
+ "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
+ },
2252
+ {
2253
+ "page": "App Links & Universal Links",
2254
+ "group": "Concepts",
2255
+ "section": "How your link pages look",
2256
+ "url": "https://straitlink.in/docs/app-links/#how-your-link-pages-look",
2257
+ "markdown": "When a tap can't open your app right away, the person sees one of our pages: the iPhone \"Get the app\" page, the page inside an in-app browser (Instagram, Facebook, WhatsApp…) with **Open in app**, the password page, and the \"not found\" and \"This link has expired\" pages. They lead with your app, not with Strait. Set these optional fields in **Dashboard → Settings → How your link pages look**:\n\n| Field | Rules | Used for |\n|---|---|---|\n| App name | 1 to 60 characters, no line breaks | \"Opens Hilltop Shoes…\", \"Go to Hilltop Shoes's website\" |\n| Icon URL | `https://` only (people's phones load it). Use a square image | The icon at the top of each page |\n| Brand colour | A hex colour such as `#7c3aed` (`#abc` is fine too) | The main button. We pick black or white text on it so it stays readable (contrast at least 4.5:1) |\n\nInvalid values are refused when you save, with a message saying why. Leave them empty and the pages use neutral defaults. Every page works in light and dark mode. The page we show for a link we've disabled for safety stays unbranded on purpose."
2258
+ },
2259
+ {
2260
+ "page": "App Links & Universal Links",
2261
+ "group": "Concepts",
2262
+ "section": "Custom scheme hand-off",
2263
+ "url": "https://straitlink.in/docs/app-links/#custom-scheme-hand-off",
2264
+ "markdown": "When a tap reaches our server in a browser (for example from a web page, or on Android when the link isn't verified), we hand the link to your app using your custom scheme:\n\n```txt\nyourapp://shop.example.com/product/42?strait_click=<tap id>\n```\n\nThe SDK turns that into the destination `https://shop.example.com/product/42`, removes the tap id before your app sees it, and uses the id to join the app open to the exact tap in Analytics."
2265
+ },
2266
+ {
2267
+ "page": "How iPhone install matching works",
2268
+ "group": "Concepts",
2269
+ "section": "Navigating, not tracking",
2270
+ "url": "https://straitlink.in/docs/iphone-install-matching/#navigating-not-tracking",
2271
+ "markdown": "iOS passes nothing through the App Store. So when your app opens for the first time, it has to ask us \"which link was this?\" with nothing in hand but what the phone looks like and the network it's on.\n\nWe use that only to **open the right screen in the same app**: the screen behind the link the person just tapped. It routes one tap. We don't build a profile, follow anyone across apps or websites, or use it for advertising, and the signals are gone within about 2 days."
2272
+ },
2273
+ {
2274
+ "page": "How iPhone install matching works",
2275
+ "group": "Concepts",
2276
+ "section": "You choose the method",
2277
+ "url": "https://straitlink.in/docs/iphone-install-matching/#you-choose-the-method",
2278
+ "markdown": "Strait has two ways to do this on iPhone. **You choose** which to use, with two switches in **Dashboard → Settings → iPhone installs**:\n\n| Method | What it does | What the person sees |\n|---|---|---|\n| **Device matching** | The link page notes a few device and network details for up to an hour, and the app's first open is matched to them ([what we use](#what-we-use)). | Nothing: no prompt, no clipboard |\n| **Paste handoff** (also called the [clipboard boost](#the-clipboard-boost-paste-handoff)) | **Get the app** copies a one-time link; the app reads it on its first open. Exact, single use, 24 hours. | iOS's \"Allow Paste\" prompt, or your app's Paste button |\n\nEvery combination works, and the dashboard shows a sentence for the one you picked:\n\n| Device matching | Paste handoff | What happens on the app's first open |\n|---|---|---|\n| Off | Off | No deferred link on iPhone. People land on the app's home screen. Installs are still counted. |\n| On | Off | Device matching only. If nothing matches, the app opens on its home screen. The clipboard is never read. |\n| Off | On | Paste handoff only. No device details are kept. If the person doesn't allow the paste, the app opens on its home screen. |\n| On | On | Device matching runs first. Only if it finds nothing does the app read the clipboard, so a device match never shows the paste prompt. |\n\n**Defaults.** New workspaces start with both switches **off**, so nothing is matched until you choose. Workspaces created before 10 October 2026 keep the values they had.\n\n**No app release needed.** The choice isn't built into your app. On its first open, the SDK asks our server `/v1/match`, and the answer carries your workspace's current choice. The SDK reads the clipboard only when that answer says paste handoff is on and device matching found nothing (or is off), and it doesn't keep the setting for later. So a change in the dashboard applies to versions already in the App Store, from the next first open (within about a minute)."
2279
+ },
2280
+ {
2281
+ "page": "How iPhone install matching works",
2282
+ "group": "Concepts",
2283
+ "section": "What we use",
2284
+ "url": "https://straitlink.in/docs/iphone-install-matching/#what-we-use",
2285
+ "markdown": "When someone without your app taps **Get the app** on the link page in Safari, we save these signals with that tap. When your app opens for the first time, the SDK sends the same fields and our server compares them.\n\n| Signal | Where it comes from | How we keep it |\n|---|---|---|\n| IP address | Seen by our server on the tap and on the first launch | Only as a **keyed hash** (HMAC-SHA256 with a server-side secret). The IP itself is never stored. |\n| IPv6 /64 network | Seen by our server | Only as a keyed hash, like the IP |\n| Network prefix (IPv4 /24 or IPv6 /48) and network operator number (ASN) | Seen by our server | As is |\n| Screen size and pixel ratio | The browser, then the app | As is |\n| Language and time zone | The browser, then the app | As is |\n| iOS version (major) | The browser's user agent, then the app's | As is |\n\nThis is device data, and comparing it is a kind of device matching. We say so plainly so you can describe it accurately to your users and to Apple."
2286
+ },
2287
+ {
2288
+ "page": "How iPhone install matching works",
2289
+ "group": "Concepts",
2290
+ "section": "How long we keep it, and what it's used for",
2291
+ "url": "https://straitlink.in/docs/iphone-install-matching/#how-long-we-keep-it-and-what-its-used-for",
2292
+ "markdown": "- A tap's signals can be matched for **1 hour**. Our nightly cleanup erases them once that hour is a day old, so they're gone **within about 2 days of the tap** (at most 49 hours).\n- They're used **only** to open the right screen in the same app that owns the link.\n- One tap gives **at most one** match.\n- When two taps look alike (for example two identical iPhones on one Wi-Fi network tapping different links), we **refuse** to match either of them rather than guess. The app then opens normally on its home screen."
2293
+ },
2294
+ {
2295
+ "page": "How iPhone install matching works",
2296
+ "group": "Concepts",
2297
+ "section": "What we never do",
2298
+ "url": "https://straitlink.in/docs/iphone-install-matching/#what-we-never-do",
2299
+ "markdown": "- No advertising, and no measuring of advertising.\n- No sharing or selling of this data, and no data brokers.\n- No linking across apps: a match only ever happens inside the workspace that owns the link.\n- No advertising identifier (IDFA), and no reading of anything else on the phone."
2300
+ },
2301
+ {
2302
+ "page": "How iPhone install matching works",
2303
+ "group": "Concepts",
2304
+ "section": "Why there's no App Tracking Transparency prompt",
2305
+ "url": "https://straitlink.in/docs/iphone-install-matching/#why-theres-no-app-tracking-transparency-prompt",
2306
+ "markdown": "Apple defines tracking as linking data collected in your app with other companies' data for targeted advertising or advertising measurement, or sharing it with data brokers ([Apple: User privacy and data use](https://developer.apple.com/app-store/user-privacy-and-data-use/)). Install matching does none of that: the data stays with your workspace and is used only to open your own app's screen. So it isn't tracking under that definition, and the SDK doesn't ask for ATT.\n\nTo be honest about the other side: Apple's page on required-reason APIs also says that fingerprinting is not allowed, whether or not a user gives permission to track. ATT doesn't change that. Device matching sits close to that line, and Apple decides what counts, so there is a risk that App Review flags it. That's why the choice is yours: device matching is off for new workspaces, you can [turn it on or off](#the-off-switch) at any time, and [paste handoff](#the-clipboard-boost-paste-handoff) on its own gives an exact match only after the person pastes, with no device details kept. The dashboard shows this note next to the Device matching switch. Each customer decides what's right for their app."
2307
+ },
2308
+ {
2309
+ "page": "How iPhone install matching works",
2310
+ "group": "Concepts",
2311
+ "section": "The off-switch",
2312
+ "url": "https://straitlink.in/docs/iphone-install-matching/#the-off-switch",
2313
+ "markdown": "**Dashboard → Settings → iPhone installs → Device matching.** It's off for new workspaces; workspaces created before 10 October 2026 keep the value they had. Each switch saves as soon as you flip it.\n\nWhen you turn it off:\n\n- The link page stops saving these signals, and our server stores none.\n- Signals already saved for your workspace are erased.\n- A first launch on iPhone gets no match, so the app opens on its home screen (unless paste handoff finds the link).\n\nAndroid isn't affected: it uses the Google Play Install Referrer, which carries the exact link through the install (see [Deferred deep linking](https://straitlink.in/docs/deferred-deep-linking/))."
2314
+ },
2315
+ {
2316
+ "page": "How iPhone install matching works",
2317
+ "group": "Concepts",
2318
+ "section": "The clipboard boost (paste handoff)",
2319
+ "url": "https://straitlink.in/docs/iphone-install-matching/#the-clipboard-boost-paste-handoff",
2320
+ "markdown": "Paste handoff, which we also call the clipboard boost, gives an **exact** match instead of a signal match, but only when the person pastes. It's off by default. Turn it on in **Dashboard → Settings → iPhone installs → Paste handoff**; that one switch is all it takes. The SDK learns it from `/v1/match` on the app's first open, so there is nothing to change in your app. (React Native, Flutter and Unity apps pass a clipboard adapter once, so the SDK can read the clipboard when your workspace asks for it.)\n\nThe old SDK option `clipboardBoost: true` is **deprecated and ignored**: the dashboard switch decides. You can leave it in your code or remove it.\n\nHow it works:\n\n1. On the link page, **Get the app** also copies a one-time link to the clipboard. The link holds a random token. We store only a hash of it, it works **once**, and it expires after **24 hours**.\n2. On the app's first launch, the SDK asks `/v1/match` first. If device matching is on and finds the install, the SDK never touches the clipboard and no prompt appears. Only if there's no match and the answer says paste handoff is on does the SDK check whether the clipboard probably holds a web link. iOS answers that without showing anything. If the server can't be reached, the SDK leaves the clipboard alone and tries again on the next launch.\n3. Only if it does, the SDK reads the clipboard. **This shows iOS's \"Allow Paste\" prompt.** If the person allows it and the text is one of your handoff links, the SDK sends just the token to our server and the app opens the exact screen.\n4. Instead of the prompt, you can show Apple's **Paste** button (`UIPasteControl` or SwiftUI `PasteButton`; the iOS SDK includes `StraitPasteButton`). Tapping it is the person's consent, and iOS shows no prompt.\n\nThe SDK never reads the clipboard unless your workspace has paste handoff on, and it sends nothing but a Strait handoff token. Anything else on the clipboard stays on the phone. If there's no token, or it was already used or has expired, the app gets the device-matching result. With both on, device matching runs first on the first launch and the clipboard is read only when it finds nothing, so most people never see the paste prompt. Both attempts count as one install."
2321
+ },
2322
+ {
2323
+ "page": "How iPhone install matching works",
2324
+ "group": "Concepts",
2325
+ "section": "App Store privacy label",
2326
+ "url": "https://straitlink.in/docs/iphone-install-matching/#app-store-privacy-label",
2327
+ "markdown": "A ready-made answer for **App Store Connect → App Privacy**, for the data the Strait SDK handles. It matches the SDK's privacy manifest (`PrivacyInfo.xcprivacy`). Add whatever else your own app collects.\n\n| Data type | Purpose | Used for tracking | Linked to the user |\n|---|---|---|---|\n| **Coarse Location** (derived from the IP address) | App Functionality | No | No |\n| **Other Data** (screen size, language, time zone, iOS version) | App Functionality | No | No |\n| **Product Interaction** (link opens) | App Functionality; also Analytics if you use Strait analytics | No | No |\n\n- **Used for tracking: No.** Nothing is combined with other companies' data for advertising or shared with data brokers.\n- **Linked to the user: No**, because Strait has no user accounts or identity: no names, emails, phone numbers or advertising IDs. If you join Strait data (for example the tap id on a purchase) to your own user accounts, answer \"Linked to the user\" accordingly.\n- The clipboard boost sends only a one-time token that Strait created, not the person's clipboard contents.\n\nOur full data list is in the [Privacy policy](https://straitlink.in/legal/privacy/)."
2328
+ },
2329
+ {
2330
+ "page": "Analytics: how we count",
2331
+ "group": "Concepts",
2332
+ "section": "The funnel",
2333
+ "url": "https://straitlink.in/docs/analytics/#the-funnel",
2334
+ "markdown": "```txt\nTaps → Where we sent them → App opened → Installs → Purchases\n```\n\nEach step is joined to the step before it with a tap id, so you can follow one tap all the way to revenue."
2335
+ },
2336
+ {
2337
+ "page": "Analytics: how we count",
2338
+ "group": "Concepts",
2339
+ "section": "Taps",
2340
+ "url": "https://straitlink.in/docs/analytics/#taps",
2341
+ "markdown": "**Taps** are each time someone tapped one of your links.\n\n- Taps that go through our server in a browser are counted when they arrive.\n- A verified link can open your app directly from WhatsApp, Gmail and so on without touching our server. Those are counted when your app reports the open through the SDK.\n- If a browser loads the link and then hands it to your app (Edge does this), it's still one tap.\n- Link-preview robots are never counted.\n\n**Where we sent them** is decided per device: app or store, app or website, store, website, the iPhone \"Get the app\" page, or the in-app-browser page. On Android the phone then makes the final choice: your app if installed, otherwise the store or website."
2342
+ },
2343
+ {
2344
+ "page": "Analytics: how we count",
2345
+ "group": "Concepts",
2346
+ "section": "App opens",
2347
+ "url": "https://straitlink.in/docs/analytics/#app-opens",
2348
+ "markdown": "- **Opened the app**: browser taps where your app then opened and reported that exact tap, plus verified-link opens.\n- **Open rate**: taps that opened the app (directly or via the browser), divided by all taps.\n- **New users**: the first launch after installing the app. **Existing users**: already had it installed.\n- **App state** at open: *closed* (the link launched the app), *background* (it brought the app back), *foreground* (the app was already on screen).\n- **Unresolved opens**: a link opened the app but couldn't be resolved (expired, deleted, wrong workspace), so the person didn't land on the right screen.\n\nOpens are reported once each. If the phone is offline, the SDK saves the report and sends it later (up to 7 days), and duplicates are ignored."
2349
+ },
2350
+ {
2351
+ "page": "Analytics: how we count",
2352
+ "group": "Concepts",
2353
+ "section": "Installs",
2354
+ "url": "https://straitlink.in/docs/analytics/#installs",
2355
+ "markdown": "- **Installs from links**: first launches of a new install that we tied to one of your links.\n- **Joined to the exact tap**: of those, the ones matched to the precise tap (Play install referrer or browser hand-off), not just the link.\n- **Unattributed installs**: first launches no link could be found for: store search, word of mouth, or a match that failed.\n- **Match rate**: installs reconnected to a tap, divided by all attributable installs.\n- **Time to install**: the median time between tap and install, for installs matched to their tap.\n- **Probably reached the store / website**: store-bound browser taps where the app never opened and no install followed. We can't see the store page itself, so these are estimates and labelled as such."
2356
+ },
2357
+ {
2358
+ "page": "Analytics: how we count",
2359
+ "group": "Concepts",
2360
+ "section": "Purchases",
2361
+ "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."
2363
+ },
2364
+ {
2365
+ "page": "Analytics: how we count",
2366
+ "group": "Concepts",
2367
+ "section": "What no one can measure",
2368
+ "url": "https://straitlink.in/docs/analytics/#what-no-one-can-measure",
2369
+ "markdown": "**An install that is never opened.** Until your app launches, no linking product can see it. Only Google Play Console and App Store Connect report raw installs. Expect our install numbers to be lower than the stores' for that reason."
2370
+ },
2371
+ {
2372
+ "page": "Analytics: how we count",
2373
+ "group": "Concepts",
2374
+ "section": "What we store",
2375
+ "url": "https://straitlink.in/docs/analytics/#what-we-store",
2376
+ "markdown": "Taps keep the network prefix (first three parts of the IP address, never the full address), platform, device class and OS version. App opens store the host and path of the URL, never the query string. See the [Privacy Policy](https://straitlink.in/legal/privacy/)."
2377
+ },
2378
+ {
2379
+ "page": "Keys",
2380
+ "group": "Concepts",
2381
+ "section": "Two keys",
2382
+ "url": "https://straitlink.in/docs/keys/#two-keys",
2383
+ "markdown": "| Key | Looks like | Where it goes | What it can do |\n|---|---|---|---|\n| Publishable key | `st_pub_live_…` | Your app and website | Resolve links, report opens, match installs, send events |\n| Secret key | `st_live_…` | Your server only | Create, list, update and deactivate links, export, send events |\n\nThe publishable key is in **Dashboard → Get started**. Secret keys are in **Dashboard → Settings → Secret keys**. Never put a secret key in an app or a web page: anyone can read it from there. When your app needs a new link (a share button, a referral), it asks your server, and your server calls the API with the secret key. See [Create links in the app](https://straitlink.in/docs/sdks/react-native/create-links/)."
2384
+ },
2385
+ {
2386
+ "page": "Keys",
2387
+ "group": "Concepts",
2388
+ "section": "Rotating keys",
2389
+ "url": "https://straitlink.in/docs/keys/#rotating-keys",
2390
+ "markdown": "**Publishable key.** Rotate it in **Dashboard → Get started**. You get a new key right away, and the old one keeps working for **7 days**, so apps already on people's phones keep working while you ship an update with the new key. During those 7 days, responses to the old key carry a `Strait-Key-Deprecated` header with the time it stops working (ISO 8601, for example `2026-01-08T12:00:00.000Z`). After that, the old key gets `401`. If you rotate again within the 7 days, the key from the earlier rotation stops at once: only one old key is honoured.\n\n**Secret keys.** You can have several. Each one in **Dashboard → Settings → Secret keys** shows when it was last used (\"never used\" if it hasn't been), so you can spot a key nothing uses any more before you revoke it. We store only a hash, so a new key is shown once. Revoking a secret key stops it immediately. To replace one without downtime, create the new key, deploy it, check the old key's last-used date has stopped moving, then revoke the old one."
2391
+ },
2392
+ {
2393
+ "page": "Keys",
2394
+ "group": "Concepts",
2395
+ "section": "If a secret key leaks",
2396
+ "url": "https://straitlink.in/docs/keys/#if-a-secret-key-leaks",
2397
+ "markdown": "Revoke it in **Dashboard → Settings → Secret keys** and create a new one. Revoking takes effect on the next request. Then check **Links** for changes you didn't make."
2398
+ },
2399
+ {
2400
+ "page": "Node (server)",
2401
+ "group": "Server & API",
2402
+ "section": "Install",
2403
+ "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."
2405
+ },
2406
+ {
2407
+ "page": "Node (server)",
2408
+ "group": "Server & API",
2409
+ "section": "Create the client",
2410
+ "url": "https://straitlink.in/docs/sdks/node/#create-the-client",
2411
+ "markdown": "```ts\nimport { StraitClient } from '@straitlink/node';\n\nconst links = new StraitClient({\n apiKey: process.env.SECRET_KEY!, // st_live_… from Dashboard → Settings → Secret keys\n baseUrl: 'https://strait.link',\n});\n```"
2412
+ },
2413
+ {
2414
+ "page": "Node (server)",
2415
+ "group": "Server & API",
2416
+ "section": "Handle deep links",
2417
+ "url": "https://straitlink.in/docs/sdks/node/#handle-deep-links",
2418
+ "markdown": "A server doesn't open links: it creates them, and hears about every tap and open through [webhooks](https://straitlink.in/docs/webhooks/) (`link.clicked`, `app.opened`).\n\n```ts\n// Create a link\nconst link = await links.createLink({\n slug: 'launch',\n longUrl: 'https://yourapp.com/launch',\n tags: ['campaign'],\n});\n\n// List / update / deactivate\nawait links.listLinks();\nawait links.updateLink('launch', { longUrl: 'https://yourapp.com/launch-v2' });\nawait links.deleteLink('launch');\n```"
2419
+ },
2420
+ {
2421
+ "page": "Node (server)",
2422
+ "group": "Server & API",
2423
+ "section": "Deferred deep links",
2424
+ "url": "https://straitlink.in/docs/sdks/node/#deferred-deep-links",
2425
+ "markdown": "Deferred matching happens in the app, through the app SDKs. Your server hears about each match as an `install.matched` [webhook](https://straitlink.in/docs/webhooks/), with the link and the tap id (`clickId`)."
2426
+ },
2427
+ {
2428
+ "page": "Node (server)",
2429
+ "group": "Server & API",
2430
+ "section": "Events",
2431
+ "url": "https://straitlink.in/docs/sdks/node/#events",
2432
+ "markdown": "```ts\n// Send a conversion / revenue event (no publishable key needed server-side)\nawait links.sendEvent({ event: 'purchase', value: 49.99, currency: 'USD' });\n\n// Name the tap it came from, when you know it (for example from a webhook)\nawait links.sendEvent({ event: 'purchase', value: 49.99, currency: 'USD', clickId });\n```\n\nOn a server nothing is remembered for you: `sendEvent` passes `clickId` through only when you give it. Use a tap id you received, such as `clickId` from an `app.opened` or `install.matched` webhook. The server keeps it only if it's a tap of your workspace; otherwise it drops the id and still records the event.\n\nFailures throw a `StraitError` with `.status` and the parsed `.body`.\n\n:::warn Keep the secret key on the server\nThe secret key can create and change links. Store it in an environment variable and never ship it in an app, a web page or a public repository. If it leaks, revoke it in the dashboard and create a new one.\n:::"
2433
+ },
2434
+ {
2435
+ "page": "Node (server)",
2436
+ "group": "Server & API",
2437
+ "section": "Methods",
2438
+ "url": "https://straitlink.in/docs/sdks/node/#methods",
2439
+ "markdown": "| Method | Endpoint |\n|---|---|\n| `createLink(input)` | [`POST /v1/links`](https://straitlink.in/docs/api/create-link/) |\n| `listLinks()` | [`GET /v1/links`](https://straitlink.in/docs/api/list-links/) |\n| `updateLink(slug, fields)` | [`PATCH /v1/links/:slug`](https://straitlink.in/docs/api/update-link/) |\n| `deleteLink(slug)` | [`DELETE /v1/links/:slug`](https://straitlink.in/docs/api/deactivate-link/) |\n| `sendEvent(input)` | [`POST /v1/event`](https://straitlink.in/docs/api/send-event/) |\n\nFull request and response details are in the [REST API reference](https://straitlink.in/docs/api/)."
2440
+ },
2441
+ {
2442
+ "page": "Node (server)",
2443
+ "group": "Server & API",
2444
+ "section": "Common errors",
2445
+ "url": "https://straitlink.in/docs/sdks/node/#common-errors",
2446
+ "markdown": "- **`StraitClient: apiKey is required` or `baseUrl must use https://`.** The constructor checks both. Plain `http` works only for `localhost`, `127.0.0.1` and `::1`.\n- **`StraitError` with `status` 401.** The key is wrong, revoked, or a publishable key. The link API needs a secret key.\n- **`StraitError` with `status` 402.** A plan limit: `.body.reason` is `links` (new links this month) or `clicks` (taps this month). Links you already have keep working.\n- **Any other `StraitError`.** `.message` is the API's `error`; every one is in the [Error reference](https://straitlink.in/docs/errors/)."
2447
+ },
2448
+ {
2449
+ "page": "REST API",
2450
+ "group": "Server & API",
2451
+ "section": "Base URL",
2452
+ "url": "https://straitlink.in/docs/api/#base-url",
2453
+ "markdown": "```txt\nhttps://strait.link\n```\n\nEvery endpoint has its own page, with the request in curl, Node, Python, PHP, Java and Go next to the response: see [Endpoints](#endpoints) below. The PHP, Java and Go examples use only the standard library (ext-curl, `java.net.http`, `net/http`).\n\nServer recipes: [create links in bulk](https://straitlink.in/docs/recipes/batch-create-links/), [verify webhook signatures](https://straitlink.in/docs/recipes/verify-webhook-signatures/) and [send links in WhatsApp templates](https://straitlink.in/docs/recipes/whatsapp-template-links/).\n\n- **[Download the OpenAPI description](https://straitlink.in/docs/api/openapi.json)** (also served live at `https://strait.link/v1/openapi.json`) for Insomnia or a code generator.\n- **[Download the Postman collection](https://straitlink.in/docs/api/postman-collection.json)**: in Postman, **Import**, pick the file, then set `secretKey` and `publishableKey` in the collection's variables."
2454
+ },
2455
+ {
2456
+ "page": "REST API",
2457
+ "group": "Server & API",
2458
+ "section": "Authentication",
2459
+ "url": "https://straitlink.in/docs/api/#authentication",
2460
+ "markdown": "| Key | Send it as | Use it for |\n|---|---|---|\n| Secret key `st_live_…` | `Authorization: Bearer st_live_…` header | Link endpoints, and events from your server |\n| Publishable key `st_pub_live_…` | `\"publishableKey\"` in the JSON body | Endpoints your app or website calls |\n\nThe publishable key is in **Dashboard → Get started** and secret keys are in **Dashboard → Settings → Secret keys**. The examples on this page read the secret key from an environment variable:\n\n```bash\nexport SECRET_KEY=st_live_… # Dashboard → Settings → Secret keys\n```\n\n The publishable key is safe to ship in apps; it can only resolve links, report opens, match installs and send events. Requests with a missing or wrong key get `401`.\n\nAfter you rotate the publishable key, the old key keeps working for 7 days so apps already on people's phones don't break. Responses to the old key carry a `Strait-Key-Deprecated` header with the time it stops working (ISO 8601, for example `Strait-Key-Deprecated: 2026-01-08T12:00:00.000Z`); after that it gets `401`. See [Keys](https://straitlink.in/docs/keys/#rotating-keys)."
2461
+ },
2462
+ {
2463
+ "page": "REST API",
2464
+ "group": "Server & API",
2465
+ "section": "Errors and limits",
2466
+ "url": "https://straitlink.in/docs/api/#errors-and-limits",
2467
+ "markdown": "Every error under `/v1` is JSON with an `error` message, never an HTML page:\n\n```json\n{ \"error\": \"slug already exists in this workspace\" }\n```\n\n| Status | When |\n|---|---|\n| `400` | Invalid input, including a body that isn't valid JSON (`{ \"error\": \"malformed JSON body\" }`) |\n| `401` | Missing or wrong key |\n| `402` | Plan limit reached when creating a link. Adds `reason` (`\"links\"`, or `\"clicks\"` for the monthly tap limit) and `upgradeUrl`; existing links keep working |\n| `404` | Not found, including unknown `/v1` paths (`{ \"error\": \"not found\" }`) |\n| `409` | The slug is already used by an active link in your workspace |\n| `413` | Request body too large |\n| `429` | Rate limited |\n| `500` | Our fault: `{ \"error\": \"internal error\" }`, with no internal details |\n\nSome errors add fields, for example a blocked destination returns `{ \"error\": \"destination_blocked\", \"reason\": \"…\" }`. The endpoints apps call are rate-limited per IP address; normal SDK traffic is far below the limit."
2468
+ },
2469
+ {
2470
+ "page": "REST API",
2471
+ "group": "Server & API",
2472
+ "section": "Short URLs and slugs",
2473
+ "url": "https://straitlink.in/docs/api/#short-urls-and-slugs",
2474
+ "markdown": "Slugs are unique within your workspace, not across Strait: another workspace can also have `/summer` on its own link domain. Every link the API returns includes `shortUrl`, the address to share: `https://<your-handle>.strait.link/<slug>`. Always share `shortUrl`. See [Link domains](https://straitlink.in/docs/link-domains/).\n\nFetch one link by its slug with `GET /v1/links/{slug}`."
2475
+ },
2476
+ {
2477
+ "page": "REST API",
2478
+ "group": "Server & API",
2479
+ "section": "When changes take effect",
2480
+ "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)."
2482
+ },
2483
+ {
2484
+ "page": "REST API",
2485
+ "group": "Server & API",
2486
+ "section": "Paging",
2487
+ "url": "https://straitlink.in/docs/api/#paging",
2488
+ "markdown": "`GET /v1/links` without parameters returns every active link as a plain JSON array. Add `limit` (1 to 100, default 50) or `cursor` to page through them instead, newest first:\n\n```bash\ncurl \"https://strait.link/v1/links?limit=50\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n# → { \"links\": [ … ], \"nextCursor\": \"WyIyMDI2…\" }\n\ncurl \"https://strait.link/v1/links?limit=50&cursor=WyIyMDI2…\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n# → { \"links\": [ … ], \"nextCursor\": null } ← the last page\n```\n\nPass each `nextCursor` back as `cursor` until it is `null`. Treat the cursor as opaque; an invalid one gets `400`."
2489
+ },
2490
+ {
2491
+ "page": "REST API",
2492
+ "group": "Server & API",
2493
+ "section": "Try a link without tapping it",
2494
+ "url": "https://straitlink.in/docs/api/#try-a-link-without-tapping-it",
2495
+ "markdown": "`GET /v1/simulate` tells you what a tap would do, without recording a tap, sending webhooks or counting toward your plan. Pass the full short link as `url` and either a raw user agent or one of these presets as `ua`: `android`, `iphone`, `ipad`, `desktop`, `instagram-ios`, `instagram-android`, `facebook-android`, `whatsapp-android`, `bot`.\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fsummer&ua=instagram-android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\nThe answer has the `decision` (`redirect`, `interstitial`, `escape`, `preview`, `expired` or `blocked`), `sentTo` (the same values as Analytics, below), the `location` the visitor would be sent to (or the main button's target on a page), and a plain-English `reason`. Hand-off URLs in it carry a placeholder tap id, `00000000-0000-4000-8000-000000000000`. You can also authenticate with `publishableKey=` as a query parameter; the link must belong to the key's workspace. **Dashboard → Playground** runs the same check."
2496
+ },
2497
+ {
2498
+ "page": "REST API",
2499
+ "group": "Server & API",
2500
+ "section": "How taps are labelled",
2501
+ "url": "https://straitlink.in/docs/api/#how-taps-are-labelled",
2502
+ "markdown": "Each tap is recorded with where we sent it, `sent_to` (in the taps export and the `sentTo` webhook field):\n\n| `sent_to` | Meaning |\n|---|---|\n| `app_or_store` | Android: your app if installed, otherwise the store |\n| `app_or_website` | Android: your app if installed, otherwise your website |\n| `store` | Directly to Google Play or the App Store |\n| `website` | Directly to the destination or fallback website |\n| `app_store_page` | The iPhone \"Get the app\" page |\n| `in_app_browser_page` | The page shown inside an in-app browser (Instagram, Facebook, WhatsApp…) with \"Open in app\" |\n| `expired` | The link had expired: the visitor saw \"This link has expired\". Recorded, never billed, no webhook |\n\nTaps decided at Cloudflare's edge and taps served by our server are recorded with the same fields and labels.\n\nA tap's **channel** is the first of these that has a value: the tap URL's `utm_source`, the link destination's `utm_source`, the in-app browser it was tapped in (`whatsapp`, `instagram`…), then the referring site. Taps with none of these are direct.\n\nA link's **campaign** is the `campaign` you set. If you leave it out, it's filled in from the destination's `utm_campaign`, and follows it when you change the destination."
2503
+ },
2504
+ {
2505
+ "page": "REST API",
2506
+ "group": "Server & API",
2507
+ "section": "Extra link fields",
2508
+ "url": "https://straitlink.in/docs/api/#extra-link-fields",
2509
+ "markdown": "`POST /v1/links` also accepts these optional fields (all URLs must be https):\n\n| Field | Purpose |\n|---|---|\n| `iosFallbackUrl`, `androidFallbackUrl`, `desktopUrl` | Where to send people on that platform when the app can't open |\n| `title`, `description`, `imageUrl` | The preview shown in chat apps and social posts |\n| `campaign` | Campaign name for analytics (also passed to the App Store as the campaign token). Defaults to the destination's `utm_campaign` |\n| `tags` | Labels for filtering |\n| `expiresAt` | When the link expires: epoch milliseconds or an ISO 8601 date-time |\n| `expiredAction`, `expiredUrl` | Where taps go after expiry: `page`, `store` or `url` (with an https `expiredUrl`). Leave out to use the workspace setting. See [When a link expires](https://straitlink.in/docs/short-links/#when-a-link-expires) |\n\n`PATCH /v1/links/{slug}` changes `longUrl`, `title`, `tags`, `expiresAt`, `expiredAction` and `expiredUrl`. Send only what changes; `null` clears `expiresAt` (never expires) and `expiredAction` (back to the workspace setting)."
2510
+ },
2511
+ {
2512
+ "page": "REST API",
2513
+ "group": "Server & API",
2514
+ "section": "Batch create",
2515
+ "url": "https://straitlink.in/docs/api/#batch-create",
2516
+ "markdown": "`POST /v1/links/batch` creates up to 500 links in one call, for imports and migrations. Send `{ \"links\": [ … ] }`, where each item takes the same fields as `POST /v1/links`. Every item is checked the same way (slug rules, https destinations, the unsafe-destination check, plan limits) and gets its own result, so one bad item never fails the batch:\n\n```bash\ncurl https://strait.link/v1/links/batch \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"links\": [ { \"slug\": \"spring\", \"longUrl\": \"https://shop.example.com/spring\" },\n { \"slug\": \"summer\", \"longUrl\": \"https://shop.example.com/summer\", \"campaign\": \"summer_2026\" } ] }'\n# → { \"results\": [ { \"index\": 0, \"ok\": true, \"status\": 201, \"link\": { … } }, … ],\n# \"summary\": { \"total\": 2, \"created\": 2, \"failed\": 0 } }\n```\n\nThe response is `200` whenever the request itself is valid; read each item's `ok` and `status` (`201`, `400`, `402` or `409`). A slug that already exists, or repeats earlier in the same batch, is a `409` for that item only, so re-running an import is safe. Items past a plan limit get `402`. See [Migrate from Branch](https://straitlink.in/docs/migrate-from-branch/)."
2517
+ },
2518
+ {
2519
+ "page": "REST API",
2520
+ "group": "Server & API",
2521
+ "section": "Export",
2522
+ "url": "https://straitlink.in/docs/api/#export",
2523
+ "markdown": "Export everything in your workspace at any time, on every plan. It gives you what you need to leave, or to keep your links running if Strait ever shuts down. Send your secret key; a publishable key gets `401`.\n\n```txt\nGET https://strait.link/v1/export?part=links|taps|events|webhooks|worker\nAuthorization: Bearer st_live_…\n```\n\n| `part` | What you get |\n|---|---|\n| `links` | NDJSON, one link per line: slug, destination, platform fallbacks and whether it is active |\n| `taps` | CSV of every tap, joined to the app opens and installs that came from it. No date cap; add optional `from` and `to` (ISO dates) to narrow it |\n| `events` | CSV of every conversion event (signups, purchases, revenue): `created_at, name, value, currency, platform, link_id, slug, click_id, channel, campaign, variant`. `channel`, `campaign` and `variant` come from the tap the event is attributed to, when it has a `click_id`. Oldest first, no date cap; optional `from` and `to` |\n| `webhooks` | JSON of your webhook settings: `url`, `events`, `active` and `createdAt` for each one. Signing secrets are never exported; make new ones when you recreate a webhook |\n| `worker` | A single-file Cloudflare Worker with the Android, iPhone and desktop fallbacks built in. Deploy it on your own account and route a link domain you own to it, and links on that domain keep redirecting. It can't serve `strait.link` links (that domain is ours), and custom domains are coming soon |\n\n```bash\ncurl \"https://strait.link/v1/export?part=events&from=2026-01-01\" \\\n -H \"Authorization: Bearer $SECRET_KEY\" -o events.csv\n```\n\nTaps and events are streamed page by page, so an export of millions of rows starts right away and never times out on our side.\n\n### Download from the dashboard\n\nOwners and admins can download every part from **Dashboard → Settings → Download everything**: one button per file, no secret key needed. Members and viewers can't, because an export contains all of the workspace's data. The same section keeps the commands above under **Or from your server**.\n\nEach button asks for a one-time download link, then your browser saves the file directly, however large:\n\n```txt\nPOST https://strait.link/v1/export/token {\"part\": \"events\"}\n→ 201 {\"token\": \"stx_…\", \"expiresAt\": \"…\", \"path\": \"/v1/export?part=events&token=stx_…\"}\nGET https://strait.link/v1/export?part=events&token=stx_…\n```\n\nThe token works once, for 60 seconds, for that part (and its `from` / `to`) only. Only a hash of it is stored. A used or expired token gets `401`.\n\nThe SDKs call the publishable-key endpoints for you; you only need those if you're writing your own client."
2524
+ },
2525
+ {
2526
+ "page": "Webhooks",
2527
+ "group": "Server & API",
2528
+ "section": "Set up",
2529
+ "url": "https://straitlink.in/docs/webhooks/#set-up",
2530
+ "markdown": "In **Dashboard → Settings → Webhooks**, add an `https://` URL and pick events. You'll see the **signing secret** once; store it on your server. Free includes 1 webhook, Starter 3 and Scale 10 ([pricing](https://straitlink.in/pricing/)).\n\nThe URL must be a public `https://` address. When you save it, we refuse:\n\n- `http://` and other schemes, and URLs with a username or password in them\n- `localhost`, and hosts ending in `.localhost`, `.local` or `.internal`\n- IP addresses in private, loopback, link-local, carrier-grade NAT (100.64.0.0/10), multicast and reserved ranges, IPv4 and IPv6\n\nWe check again before every delivery, after looking up the hostname, because DNS can change after you save. A URL that now points at a private address isn't sent to and isn't retried."
2531
+ },
2532
+ {
2533
+ "page": "Webhooks",
2534
+ "group": "Server & API",
2535
+ "section": "Send a test event",
2536
+ "url": "https://straitlink.in/docs/webhooks/#send-a-test-event",
2537
+ "markdown": "Press **Send test event** next to a webhook in **Dashboard → Settings → Webhooks**. We send one `ping` event to it right away, signed like every other delivery, and show what your server answered: the status code and how long it took. Your server's response body is never shown back. It works on a paused webhook too. Test events aren't stored or retried.\n\nFrom your server, call the same thing with your secret key:\n\n```bash\ncurl -X POST https://strait.link/v1/webhooks/<webhook id>/test \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n```json title=\"Response\" paper\n{\n \"delivered\": true,\n \"status\": 204,\n \"error\": null,\n \"durationMs\": 182,\n \"deliveryId\": \"0b9c3f0e-5a7d-4a51-9a2e-3f1c8d2b6e47\",\n \"event\": \"ping\"\n}\n```\n\nWhen the delivery fails, `delivered` is `false` and `error` says why: `HTTP 500` (your server's status), `timeout` (no answer within 5 seconds) or `unsafe_target` (the URL resolves to a private address). An unknown webhook id, or one from another workspace, gets `404`."
2538
+ },
2539
+ {
2540
+ "page": "Webhooks",
2541
+ "group": "Server & API",
2542
+ "section": "Events",
2543
+ "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`."
2545
+ },
2546
+ {
2547
+ "page": "Webhooks",
2548
+ "group": "Server & API",
2549
+ "section": "Payload",
2550
+ "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```"
2552
+ },
2553
+ {
2554
+ "page": "Webhooks",
2555
+ "group": "Server & API",
2556
+ "section": "Verify the signature",
2557
+ "url": "https://straitlink.in/docs/webhooks/#verify-the-signature",
2558
+ "markdown": "Every delivery carries this header:\n\n```txt\nX-Strait-Signature: t=<unix seconds>,v1=<signature>\n```\n\n`v1` is the hex HMAC-SHA256 of the string `<t>.<raw request body>` (the timestamp, a full stop, then the exact bytes of the body), keyed with your webhook's signing secret. To verify:\n\n1. Read the **raw body** before parsing the JSON. Re-serialised JSON won't match.\n2. Split the header on `,` and each part on the first `=` to get `t` and `v1`.\n3. Refuse the request if `t` is more than **5 minutes** (300 seconds) away from your clock. That stops someone replaying an old delivery.\n4. Compute the HMAC of `t`, `.` and the raw body with your secret, and compare it with `v1` in **constant time**.\n\nReply `401` when any step fails. Make sure your server's clock is set by NTP.\n\n```ts title=\"Node / Express\"\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport express from 'express';\n\nconst TOLERANCE_SECONDS = 300;\n\nexport function verifyStraitSignature(secret: string, rawBody: Buffer, header: string | undefined): boolean {\n if (!header) return false;\n const parts = new Map(header.split(',').map((p) => {\n const i = p.indexOf('=');\n return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const;\n }));\n const t = parts.get('t') ?? '';\n const v1 = parts.get('v1') ?? '';\n if (!/^\\d+$/.test(t) || !v1) return false;\n if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;\n const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');\n const a = Buffer.from(expected);\n const b = Buffer.from(v1);\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\nconst app = express();\n\napp.post('/webhooks/links', express.raw({ type: 'application/json' }), (req, res) => {\n if (!verifyStraitSignature(process.env.WEBHOOK_SECRET!, req.body, req.get('X-Strait-Signature'))) {\n return res.status(401).end();\n }\n const event = JSON.parse(req.body.toString('utf8'));\n // handle event.event / event.data …\n res.status(204).end();\n});\n```\n\n```python title=\"Python / Flask\"\nimport hashlib\nimport hmac\nimport os\nimport time\nfrom typing import Optional\n\nfrom flask import Flask, abort, request\n\nTOLERANCE_SECONDS = 300\n\ndef verify_strait_signature(secret: str, raw_body: bytes, header: Optional[str]) -> bool:\n if not header:\n return False\n parts = dict(p.strip().split(\"=\", 1) for p in header.split(\",\") if \"=\" in p)\n t, v1 = parts.get(\"t\", \"\"), parts.get(\"v1\", \"\")\n if not t.isdigit() or not v1:\n return False\n if abs(time.time() - int(t)) > TOLERANCE_SECONDS:\n return False\n expected = hmac.new(secret.encode(), t.encode() + b\".\" + raw_body, hashlib.sha256).hexdigest()\n return hmac.compare_digest(expected, v1)\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/links\")\ndef strait_webhook():\n raw = request.get_data() # the exact bytes we sent\n if not verify_strait_signature(os.environ[\"WEBHOOK_SECRET\"], raw, request.headers.get(\"X-Strait-Signature\")):\n abort(401)\n event = request.get_json()\n # handle event[\"event\"] / event[\"data\"] …\n return \"\", 204\n```\n\n```go title=\"Go / net/http\"\npackage webhooks\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst toleranceSeconds = 300\n\nfunc VerifyStraitSignature(secret string, body []byte, header string, now time.Time) bool {\n\tvar t, v1 string\n\tfor _, part := range strings.Split(header, \",\") {\n\t\tk, v, _ := strings.Cut(strings.TrimSpace(part), \"=\")\n\t\tswitch k {\n\t\tcase \"t\":\n\t\t\tt = v\n\t\tcase \"v1\":\n\t\t\tv1 = v\n\t\t}\n\t}\n\tts, err := strconv.ParseInt(t, 10, 64)\n\tif err != nil || ts <= 0 || v1 == \"\" {\n\t\treturn false\n\t}\n\tif d := now.Unix() - ts; d > toleranceSeconds || d < -toleranceSeconds {\n\t\treturn false\n\t}\n\tmac := hmac.New(sha256.New, []byte(secret))\n\tmac.Write([]byte(t + \".\"))\n\tmac.Write(body)\n\texpected := hex.EncodeToString(mac.Sum(nil))\n\treturn hmac.Equal([]byte(expected), []byte(v1))\n}\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil || !VerifyStraitSignature(os.Getenv(\"WEBHOOK_SECRET\"), body, r.Header.Get(\"X-Strait-Signature\"), time.Now()) {\n\t\thttp.Error(w, \"invalid signature\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\t// json.Unmarshal(body, &event); handle event.Event / event.Data …\n\tw.WriteHeader(http.StatusNoContent)\n}\n```\n\n:::note Older receivers\nWe no longer send the first-format `X-Strait-Signature-Legacy` header (an HMAC of the body with no timestamp), because a copied delivery could be replayed with it. `X-Strait-Signature` is the only signature header. If your receiver still checks the old header, switch it to the code above.\n:::"
2559
+ },
2560
+ {
2561
+ "page": "Webhooks",
2562
+ "group": "Server & API",
2563
+ "section": "Delivery",
2564
+ "url": "https://straitlink.in/docs/webhooks/#delivery",
2565
+ "markdown": "- Each request times out after 5 seconds. Respond quickly with any `2xx` and do the work afterwards.\n- Redirects aren't followed: a `3xx` counts as a failed attempt.\n- **Retries:** anything other than a `2xx` (or no answer) is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, six attempts in total, then marked failed. Your server can be down for hours without losing events.\n- Each event has a stable id: `X-Strait-Delivery` (the same as `id` in the body) stays the same on every retry, and `X-Strait-Attempt` counts up. Store the ids you've handled and ignore repeats: delivery is **at least once**.\n- Every attempt is signed again with a fresh timestamp, so a retry six hours later still passes the 5-minute check.\n- Addresses that resolve to private networks are never sent to or retried."
2566
+ },
2567
+ {
2568
+ "page": "Webhooks",
2569
+ "group": "Server & API",
2570
+ "section": "Delivery history and redelivery",
2571
+ "url": "https://straitlink.in/docs/webhooks/#delivery-history-and-redelivery",
2572
+ "markdown": "Open **Deliveries** under a webhook in **Dashboard → Settings → Webhooks** to see its last 20 deliveries: the event, when it was created, its status (**Delivered**, **Retrying** or **Failed** after all six attempts), how many attempts it took and your server's last answer. A failed delivery, or one waiting for its next retry, has a **Redeliver** button: fix your endpoint (or change the webhook URL), press it, and we send the same event again right away. Owners, admins and members can redeliver; viewers can only look.\n\nThe same from your server, with a secret key:\n\n```txt\nGET https://strait.link/v1/webhooks/{id}/deliveries?limit=20&cursor=…\nAuthorization: Bearer st_live_…\n```\n\n```json title=\"Response\" paper\n{\n \"deliveries\": [\n {\n \"id\": \"7f3e2c1a-9b4d-4e8f-a6c5-2d1b0e9f8a73\",\n \"webhookId\": \"5d0c…\",\n \"event\": \"link.clicked\",\n \"status\": \"failed\",\n \"attempts\": 6,\n \"lastStatus\": 500,\n \"lastError\": \"HTTP 500\",\n \"nextAttemptAt\": null,\n \"createdAt\": \"2026-10-04T09:12:03.512Z\",\n \"deliveredAt\": null\n }\n ],\n \"nextCursor\": null\n}\n```\n\nNewest first, `limit` 1 to 100 (default 20); pass `nextCursor` back as `cursor` for the next page. Payloads aren't returned. `nextAttemptAt` is set while a delivery is waiting for a retry.\n\n```txt\nPOST https://strait.link/v1/webhook-deliveries/{id}/redeliver\nAuthorization: Bearer st_live_…\n```\n\nThis queues the delivery again with its attempt count reset, sends it at once to the webhook's **current** URL with the same `X-Strait-Delivery` id, and answers with the delivery after that attempt (same shape as above). If it fails again it gets the full retry schedule. You get `409` when it was already delivered, or while it's being sent (in its first 2 minutes, or claimed by a delivery worker right now): try again shortly. A delivery from another workspace is `404`.\n\nDelivery history is kept for 30 days, then deleted by a nightly job."
2573
+ },
2574
+ {
2575
+ "page": "Referrals (preview)",
2576
+ "group": "Server & API",
2577
+ "section": null,
2578
+ "url": "https://straitlink.in/docs/referrals/",
2579
+ "markdown": ":::warn Preview, not switched on yet\nReferrals are built and tested, but they are not switched on in production yet, and the details below may change before they are. Until then, use the [invite-link flow that works today](https://straitlink.in/solutions/referrals/): put the code in the link's destination and read it in `onLink`. Want to try the preview? [Talk to us](https://straitlink.in/contact/).\n:::"
2580
+ },
2581
+ {
2582
+ "page": "Referrals (preview)",
2583
+ "group": "Server & API",
2584
+ "section": "What it does",
2585
+ "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."
2587
+ },
2588
+ {
2589
+ "page": "Referrals (preview)",
2590
+ "group": "Server & API",
2591
+ "section": "Two ways to attach a code",
2592
+ "url": "https://straitlink.in/docs/referrals/#two-ways-to-attach-a-code",
2593
+ "markdown": "**One link per referrer.** Your server creates a link for each user and sets `referralCode`:\n\n```bash\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{ \"slug\": \"invite-asha\", \"longUrl\": \"https://shop.example.com/invite\", \"referralCode\": \"ASHA42\" }'\n```\n\nThe answer echoes `\"referralCode\": \"ASHA42\"`. If it doesn't, the code wasn't saved: referrals aren't switched on yet, or the save failed.\n\n**One shared link, a code per tap.** Add `strait_ref` to any of your links:\n\n```text\nhttps://hilltop.strait.link/invite?strait_ref=ASHA42\n```\n\nA tap's own `strait_ref` wins over the link's code. A tap with two different `strait_ref` values counts as having no code.\n\nA code is 1 to 64 letters, digits, `-` or `_`. Use your own user id or an invite code. Don't use an email address or a phone number: codes appear in links that people share."
2594
+ },
2595
+ {
2596
+ "page": "Referrals (preview)",
2597
+ "group": "Server & API",
2598
+ "section": "What your app gets",
2599
+ "url": "https://straitlink.in/docs/referrals/#what-your-app-gets",
2600
+ "markdown": "The first-open answer (from `/v1/referrer` on Android, `/v1/match` or `/v1/handoff/claim` on iPhone) gains one field when the matched tap carried a code:\n\n```json\n{\n \"matched\": true,\n \"longUrl\": \"https://shop.example.com/invite\",\n \"linkId\": \"lnk_…\",\n \"clickId\": \"6f1c…\",\n \"matchMethod\": \"install_referrer\",\n \"referralCode\": \"ASHA42\"\n}\n```\n\nIf the tap had no code, or the install didn't match a tap, the answer has no `referralCode`, exactly as today. The SDK update that ships with the switch-on passes it to your app as `referralCode` on the deferred link event (React Native, Flutter, Android, iOS) or on the `resolveDeferredLink` result (web). Grant rewards from your server, using the webhook below, not in the app."
2601
+ },
2602
+ {
2603
+ "page": "Referrals (preview)",
2604
+ "group": "Server & API",
2605
+ "section": "The referral.converted webhook",
2606
+ "url": "https://straitlink.in/docs/referrals/#the-referralconverted-webhook",
2607
+ "markdown": "Subscribe to `referral.converted` in Dashboard → Settings → Webhooks (it shows there with a Preview badge once referrals are switched on). Deliveries are signed like every other [webhook](https://straitlink.in/docs/webhooks/):\n\n```json\n{\n \"id\": \"…\",\n \"event\": \"referral.converted\",\n \"createdAt\": \"2026-10-05T10:40:00.000Z\",\n \"data\": {\n \"referralCode\": \"ASHA42\",\n \"linkId\": \"lnk_…\",\n \"clickId\": \"6f1c…\",\n \"platform\": \"android\",\n \"matchMethod\": \"install_referrer\"\n }\n}\n```\n\nIt fires **once per tap**. If the app retries its first-open call, or the same tap leads to a reinstall, no second event is sent. A friend who already had your app opens it directly, so there is no install and no `referral.converted` event. Tell new users from existing ones with the `app.opened` event's `isInstall` field."
2608
+ },
2609
+ {
2610
+ "page": "Referrals (preview)",
2611
+ "group": "Server & API",
2612
+ "section": "The report",
2613
+ "url": "https://straitlink.in/docs/referrals/#the-report",
2614
+ "markdown": "`GET /v1/referrals?from=2026-10-01&to=2026-10-31` (secret key) returns each code's taps and referred installs in that range, plus totals, with `\"preview\": true`. The dashboard's Referrals page shows the same numbers. A tap counts at the time of the tap, and a referred install at the time of the first open."
2615
+ },
2616
+ {
2617
+ "page": "Referrals (preview)",
2618
+ "group": "Server & API",
2619
+ "section": "Limits of the preview",
2620
+ "url": "https://straitlink.in/docs/referrals/#limits-of-the-preview",
2621
+ "markdown": "- No rewards, credits, payouts, fraud scoring or per-referrer caps. These belong in your server.\n- The code is set when a link is created. It doesn't appear on the link list or edit endpoints yet, and the batch endpoint doesn't take it.\n- Direct opens (the app was already installed) don't carry the code.\n- The endpoint and fields above are in the [REST API reference](https://straitlink.in/docs/api/), marked preview. They may change before referrals are switched on."
2622
+ },
2623
+ {
2624
+ "page": "Referrals (preview)",
2625
+ "group": "Server & API",
2626
+ "section": "Related",
2627
+ "url": "https://straitlink.in/docs/referrals/#related",
2628
+ "markdown": "- [Deferred deep linking](https://straitlink.in/docs/deferred-deep-linking/): how the destination survives the install\n- [Webhooks](https://straitlink.in/docs/webhooks/): signatures, retries and test events\n- [Referral and invite links](https://straitlink.in/solutions/referrals/): the flow that works today"
2629
+ },
2630
+ {
2631
+ "page": "Error reference",
2632
+ "group": "Server & API",
2633
+ "section": "The shape of an error",
2634
+ "url": "https://straitlink.in/docs/errors/#the-shape-of-an-error",
2635
+ "markdown": "Every error under `/v1` is JSON, never an HTML page:\n\n```json\n{ \"error\": \"slug already exists in this workspace\" }\n```\n\nSome errors add fields: `message` (a sentence you can show to a person), `reason` (which limit or check), `limit`, `resetsAt`, `hint` or `upgradeUrl`. When `error` is a single `snake_case` word it's a stable code you can branch on; the table below lists every one. Any other `error` is a plain-English sentence: show it or log it, but don't match on its exact wording, because it can change."
2636
+ },
2637
+ {
2638
+ "page": "Error reference",
2639
+ "group": "Server & API",
2640
+ "section": "What each status means",
2641
+ "url": "https://straitlink.in/docs/errors/#what-each-status-means",
2642
+ "markdown": "| Status | Meaning | What to do |\n|---|---|---|\n| `400` | The request is wrong: a missing or invalid field, or a body that isn't valid JSON | Fix the request. The message names the field |\n| `401` | No key, a wrong key, or the wrong kind of key | Send the secret key as `Authorization: Bearer st_live_…` (server), or the publishable key in the body (apps). See [Keys](https://straitlink.in/docs/keys/) |\n| `402` | Your plan doesn't include this, or a plan limit is reached | Existing links keep working. Upgrade, or wait for the next month for the tap limit |\n| `403` | Signed in, but not allowed (a viewer, or two-factor sign-in needed) | Ask an owner or admin, or sign in again with your code |\n| `404` | No such link, webhook, delivery or domain in this workspace, or an unknown `/v1` path | Check the id or slug. Another workspace's ids are always `404` |\n| `409` | It already exists, or it's busy right now | Use another slug or name, or try again in a moment |\n| `410` | The link is gone | Create a new link |\n| `413` | The body is too large | Send fewer items per call (a batch takes up to 500 links) |\n| `421` | The request reached the server directly instead of through your link domain | Use `https://strait.link` or your link domain |\n| `422` | The domain you asked the App Links checker about can't be found in DNS | Check the spelling |\n| `429` | Too many requests | Wait for `Retry-After` seconds when it's sent. See [Rate limits](https://straitlink.in/docs/rate-limits/) |\n| `500` | Our fault: `{ \"error\": \"internal error\" }`, with no internal details | Retry later. If it keeps happening, write to support@straitlink.in |\n| `502` | Our certificate provider didn't answer (custom domains) | Try again in a minute; the message says whether anything was saved |\n| `503` | The feature isn't switched on, or is read-only right now | Try again later |"
2643
+ },
2644
+ {
2645
+ "page": "Error reference",
2646
+ "group": "Server & API",
2647
+ "section": "Machine-readable codes",
2648
+ "url": "https://straitlink.in/docs/errors/#machine-readable-codes",
2649
+ "markdown": "| `error` | Status | When | What to do |\n|---|---|---|---|\n| `destination_blocked` | 400 | A link's destination failed the unsafe-destination check. `reason` says why (`invalid_url`, `scheme`, `credentials_in_url`, `ip_address`, `blocked_domain`, …) | Use a public `https` address |\n| `invalid_domain` | 400 | The App Links checker got something that isn't a domain | Send a domain such as `example.com` |\n| `refused` | 400 | The App Links checker was asked about a private address, an IP address or a non-public name | Check a public domain |\n| `invalid_report` | 400 | A link report (`POST /v1/report`) is missing a field or has a bad one; `message` says which | Fix the field |\n| `dns_failed` | 422 | The App Links checker couldn't find the domain in DNS | Check the spelling |\n| `unauthorized` | 401 | A link endpoint was called with no `Authorization` header (also an operator-only endpoint without its key) | Send `Authorization: Bearer st_live_…` |\n| `plan_required` | 402 | Custom domains on a plan without them | Upgrade to Scale. See [Custom domains](https://straitlink.in/docs/custom-domains/) |\n| `mfa_required` | 403 | A dashboard session that still needs its two-factor code | Sign in again and enter your code |\n| `fresh_mfa_required` | 403 | Closing a workspace needs a two-factor code from the last 10 minutes | Enter a current code, then try again |\n| `new_account_limit` | 429 | A new workspace reached its first-day cap on links or taps. Adds `reason` (`links` or `redirects`), `message`, `limit` and `resetsAt`, and sends `Retry-After` | Wait until `resetsAt`; the cap lifts by itself |\n| `rate_limited` | 429 | The App Links checker, the link report form or the health check was called too often | Wait for `Retry-After` |\n| `domains_not_enabled` | 503 | Custom domains aren't switched on yet | Try again once they are |\n| `domains_read_only` | 503 | Domains can't be added or removed right now | Try again later |\n\nPlan limits answer `402` with the database's sentence in `error`, a `reason` and an `upgradeUrl`:\n\n| `reason` | When |\n|---|---|\n| `links` | The plan's new links for this month are used up |\n| `clicks` | This month's taps are over twice the plan's limit |\n| `webhooks` | The plan's number of webhooks is reached |\n\nYour existing links keep redirecting whatever the plan limit; only new links and webhooks wait. Plans and their limits are on [Pricing](https://straitlink.in/pricing/)."
2650
+ },
2651
+ {
2652
+ "page": "Error reference",
2653
+ "group": "Server & API",
2654
+ "section": "Every error, by status",
2655
+ "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`"
2657
+ },
2658
+ {
2659
+ "page": "Error reference",
2660
+ "group": "Server & API",
2661
+ "section": "Related",
2662
+ "url": "https://straitlink.in/docs/errors/#related",
2663
+ "markdown": "- [REST API](https://straitlink.in/docs/api/): every endpoint, with the statuses it answers.\n- [Rate limits](https://straitlink.in/docs/rate-limits/): what answers `429`, and when to try again.\n- [Webhooks](https://straitlink.in/docs/webhooks/): failed deliveries show `HTTP <status>`, `timeout`, `unsafe_target` or `webhook_removed`."
2664
+ },
2665
+ {
2666
+ "page": "Rate limits",
2667
+ "group": "Server & API",
2668
+ "section": "How a limit works",
2669
+ "url": "https://straitlink.in/docs/rate-limits/#how-a-limit-works",
2670
+ "markdown": "Each limit is a bucket: it allows a burst of requests at once, then refills at a steady rate. While the bucket is empty you get:\n\n```txt\nHTTP/1.1 429 Too Many Requests\nRetry-After: 60\n\n{ \"error\": \"rate limited\" }\n```\n\nWhen `Retry-After` is sent, wait that many seconds before trying again. Where it isn't, back off for a second or two and retry. Every `429` body is listed in the [Error reference](https://straitlink.in/docs/errors/)."
2671
+ },
2672
+ {
2673
+ "page": "Rate limits",
2674
+ "group": "Server & API",
2675
+ "section": "Limits by endpoint",
2676
+ "url": "https://straitlink.in/docs/rate-limits/#limits-by-endpoint",
2677
+ "markdown": "| Endpoints | Burst | Then | Counted per | `Retry-After` |\n|---|---|---|---|---|\n| App and website calls: `POST /v1/match`, `/v1/referrer`, `/v1/resolve`, `/v1/open`, `/v1/event`, `/v1/handoff/claim`, `/v1/store-sheet`, and `GET /v1/simulate` | 60 | 1 a second | IP address | not sent |\n| Paste handoff: `POST /v1/handoff/claim` (on top of the line above) and opening a handoff link | 10 | 10 a minute | IP address | 60 |\n| Analytics: `GET /v1/analytics` | 30 | 1 every 2 seconds (30 a minute) | workspace | 2 |\n| Referral report: `GET /v1/referrals` | 30 | 1 every 2 seconds (30 a minute) | workspace | 2 |\n| QR codes: `GET /v1/links/{slug}/qr` | 60 | 1 a second | workspace | 1 |\n| Custom domain status: `GET /v1/domains` | 20 | 1 every 2 seconds | workspace | 5 |\n| Custom domain changes: `POST /v1/domains` and `DELETE /v1/domains/{hostname}` together | 10 | 1 every 6 minutes | workspace | 360 |\n| App Links checker: `GET /v1/tools/app-links` | 10 | 10 a minute | IP address | 60 |\n| Live tap trace: `GET /v1/trace/{st}` | 30 | 1 a second | IP address | not sent |\n| Link reports: `POST /v1/report` | 5 | 5 an hour | IP address | 3600 |\n| Deep health check: `GET /healthz/deep` | 20 | 1 every 3 seconds | IP address | 60 |\n\nPNG QR codes also share one rendering budget across all workspaces (bigger images cost more of it). When it's busy you get `429` with `Retry-After: 2`: try again shortly, or ask for `format=svg`, which doesn't use that budget.\n\nAn SDK makes one or two of the app calls per link open, so one phone stays far below 60. People behind one office or mobile-carrier network share an IP address, so their calls count together."
2678
+ },
2679
+ {
2680
+ "page": "Rate limits",
2681
+ "group": "Server & API",
2682
+ "section": "Size limits",
2683
+ "url": "https://straitlink.in/docs/rate-limits/#size-limits",
2684
+ "markdown": "| What | Limit | Answer when over |\n|---|---|---|\n| Links in one `POST /v1/links/batch` | 500 | `400` |\n| Request body for `POST /v1/links/batch` | 2 MB | `413` |\n| Request body for every other public endpoint | 100 KB | `413` |\n| Page size (`limit`) for `GET /v1/links` | 1 to 100; more is treated as 100 | `400` below 1 |\n| Page size (`limit`) for webhook deliveries | 1 to 100 | `400` |"
2685
+ },
2686
+ {
2687
+ "page": "Rate limits",
2688
+ "group": "Server & API",
2689
+ "section": "New workspaces",
2690
+ "url": "https://straitlink.in/docs/rate-limits/#new-workspaces",
2691
+ "markdown": "For its first 24 hours, a new workspace can create **25 links**, and its links can be opened **500 times**. This stops throwaway accounts from sending spam through short links; the limit lifts by itself after the first day.\n\n- Creating a link past the cap answers `429` with `{ \"error\": \"new_account_limit\", \"reason\": \"links\", \"message\", \"limit\", \"resetsAt\" }` and a `Retry-After` header.\n- A tap past the cap shows the person a \"taking a break\" page instead of the destination, with `429`. Link previews in chat apps still work."
2692
+ },
2693
+ {
2694
+ "page": "Rate limits",
2695
+ "group": "Server & API",
2696
+ "section": "Plan limits",
2697
+ "url": "https://straitlink.in/docs/rate-limits/#plan-limits",
2698
+ "markdown": "Plan limits are monthly, not per second: new links a month (Free), taps a month, and the number of webhooks. Going over answers `402` with a `reason` (`links`, `clicks` or `webhooks`) and an `upgradeUrl`. Links you already shared keep working. Each plan's numbers are on [Pricing](https://straitlink.in/pricing/)."
2699
+ },
2700
+ {
2701
+ "page": "Rate limits",
2702
+ "group": "Server & API",
2703
+ "section": "Related",
2704
+ "url": "https://straitlink.in/docs/rate-limits/#related",
2705
+ "markdown": "- [Error reference](https://straitlink.in/docs/errors/): every error the API can send.\n- [Batch create](https://straitlink.in/docs/api/#batch-create): up to 500 links in one call, for imports."
2706
+ },
2707
+ {
2708
+ "page": "Create links in bulk from your backend (batch API)",
2709
+ "group": "Server & API",
2710
+ "section": null,
2711
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/",
2712
+ "markdown": "Use the batch endpoint when you make links from data: one per product in a catalogue, per store, per referral partner, or a whole import from another provider. One call to [`POST /v1/links/batch`](https://straitlink.in/docs/api/batch-create-links/) takes up to 500 links, checks each one exactly like [`POST /v1/links`](https://straitlink.in/docs/api/create-link/), and answers with one result per item. For a single link at checkout or signup, call `POST /v1/links` instead."
2713
+ },
2714
+ {
2715
+ "page": "Create links in bulk from your backend (batch API)",
2716
+ "group": "Server & API",
2717
+ "section": "1. Build the list with your own slugs",
2718
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/#1-build-the-list-with-your-own-slugs",
2719
+ "markdown": "Give every link a slug you can work out again from your own data, like `p-42` for product 42 or `store-blr-07`. Slugs are 1 to 64 letters, digits, `_` or `-`, and unique in your workspace. Because you choose them, running the same job twice can't create a second copy: the second run gets `409` for slugs that already exist.\n\nEach item takes the same fields as a single link. Put UTM tags on the destination so Analytics knows the channel and campaign without extra work:\n\n```json title=\"One item\"\n{\n \"slug\": \"p-42\",\n \"longUrl\": \"https://shop.example.com/product/42?utm_source=catalogue&utm_campaign=diwali-2026\",\n \"title\": \"Brass diya set\",\n \"tags\": [\"catalogue\"]\n}\n```\n\n**You should see:** a list where no slug appears twice. A repeat inside one call is a `409` for the later item (\"slug repeats item 3 of this batch\")."
2720
+ },
2721
+ {
2722
+ "page": "Create links in bulk from your backend (batch API)",
2723
+ "group": "Server & API",
2724
+ "section": "2. Send it in calls of 500, with safe retries",
2725
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/#2-send-it-in-calls-of-500-with-safe-retries",
2726
+ "markdown": "The code below splits the list into calls of 500 and retries a call on a network error, `429` or `5xx`. Retrying a whole call is safe: links created by the first try come back as `409` on the second, and the code counts those as done. Any other `4xx` stops the job, because sending it again won't help.\n\n```js title=\"Node: batch-links.mjs\"\n// Node 18+ (global fetch). Creates any number of links, 500 per call; safe to re-run.\nconst API = 'https://strait.link/v1/links/batch';\nconst CHUNK = 500;\n\nexport async function createLinks(links, { secretKey = process.env.SECRET_KEY, fetchImpl = fetch, retryDelayMs = 1000 } = {}) {\n const created = [];\n const existing = [];\n const failed = [];\n for (let start = 0; start < links.length; start += CHUNK) {\n const chunk = links.slice(start, start + CHUNK);\n const data = await postWithRetry({ links: chunk }, secretKey, fetchImpl, retryDelayMs);\n for (const r of data.results) {\n const slug = chunk[r.index].slug;\n if (r.ok) created.push(r.link);\n else if (r.status === 409) existing.push(slug);\n else failed.push({ slug, status: r.status, error: r.error, reason: r.reason });\n }\n }\n return { created, existing, failed };\n}\n\nasync function postWithRetry(payload, secretKey, fetchImpl, retryDelayMs, attempts = 4) {\n for (let attempt = 1; ; attempt++) {\n let res;\n try {\n res = await fetchImpl(API, {\n method: 'POST',\n headers: { Authorization: `Bearer ${secretKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n } catch (err) {\n if (attempt >= attempts) throw err;\n }\n if (res?.ok) return res.json();\n if (res && res.status !== 429 && res.status < 500) throw new Error(`batch refused: HTTP ${res.status} ${await res.text()}`);\n if (attempt >= attempts) throw new Error(`batch failed after ${attempts} attempts (HTTP ${res?.status ?? 'network error'})`);\n await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** (attempt - 1)));\n }\n}\n```\n\n```python title=\"Python: batch_links.py\"\nimport os\nimport time\n\nimport requests\n\nAPI = \"https://strait.link/v1/links/batch\"\nCHUNK = 500\n\ndef post_with_retry(payload, attempts=4):\n for attempt in range(1, attempts + 1):\n try:\n res = requests.post(\n API,\n headers={\"Authorization\": f\"Bearer {os.environ['SECRET_KEY']}\"},\n json=payload,\n timeout=60,\n )\n except requests.RequestException:\n if attempt == attempts:\n raise\n else:\n if res.ok:\n return res.json()\n if res.status_code != 429 and res.status_code < 500:\n raise RuntimeError(f\"batch refused: HTTP {res.status_code} {res.text}\")\n if attempt == attempts:\n raise RuntimeError(f\"batch failed after {attempts} attempts (HTTP {res.status_code})\")\n time.sleep(2 ** (attempt - 1))\n\ndef create_links(links):\n created, existing, failed = [], [], []\n for start in range(0, len(links), CHUNK):\n chunk = links[start:start + CHUNK]\n for r in post_with_retry({\"links\": chunk})[\"results\"]:\n slug = chunk[r[\"index\"]][\"slug\"]\n if r[\"ok\"]:\n created.append(r[\"link\"])\n elif r[\"status\"] == 409:\n existing.append(slug)\n else:\n failed.append({\"slug\": slug, \"status\": r[\"status\"], \"error\": r.get(\"error\"), \"reason\": r.get(\"reason\")})\n return created, existing, failed\n```\n\n```php title=\"PHP: batch-links.php\"\n<?php\n// Plain PHP with ext-curl. In Laravel, run createStraitLinks() from a queued job.\n\nfunction postStraitBatch(array $payload, int $attempts = 4): array\n{\n for ($attempt = 1; ; $attempt++) {\n $ch = curl_init('https://strait.link/v1/links/batch');\n curl_setopt_array($ch, [\n CURLOPT_CUSTOMREQUEST => 'POST',\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_TIMEOUT => 60,\n CURLOPT_HTTPHEADER => [\n 'Authorization: Bearer ' . getenv('SECRET_KEY'),\n 'Content-Type: application/json',\n ],\n CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),\n ]);\n $response = curl_exec($ch);\n $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);\n if ($response !== false && $status >= 200 && $status < 300) {\n return json_decode($response, true, 512, JSON_THROW_ON_ERROR);\n }\n if ($response !== false && $status !== 429 && $status < 500) {\n throw new RuntimeException(\"batch refused: HTTP $status $response\");\n }\n if ($attempt >= $attempts) {\n throw new RuntimeException(\"batch failed after $attempts attempts (HTTP $status)\");\n }\n sleep(2 ** ($attempt - 1));\n }\n}\n\nfunction createStraitLinks(array $links): array\n{\n $created = $existing = $failed = [];\n foreach (array_chunk($links, 500) as $chunk) {\n foreach (postStraitBatch(['links' => $chunk])['results'] as $r) {\n $slug = $chunk[$r['index']]['slug'];\n if ($r['ok']) {\n $created[] = $r['link'];\n } elseif ($r['status'] === 409) {\n $existing[] = $slug;\n } else {\n $failed[] = ['slug' => $slug, 'status' => $r['status'], 'error' => $r['error'] ?? null];\n }\n }\n }\n return [$created, $existing, $failed];\n}\n```\n\n```java title=\"Java: StraitBatch.java\"\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\n\n// Java 17+. Sends one call of up to 500 links (a JSON body you build with Jackson or Gson)\n// and returns the response JSON. Split bigger lists into chunks of 500 and read each\n// results[i]: ok, or status 409 (already exists), or another status (fix that item).\npublic final class StraitBatch {\n private static final HttpClient HTTP = HttpClient.newHttpClient();\n\n private StraitBatch() {}\n\n public static String postWithRetry(String linksJson) throws IOException, InterruptedException {\n int attempts = 4;\n for (int attempt = 1; ; attempt++) {\n HttpRequest request = HttpRequest.newBuilder(URI.create(\"https://strait.link/v1/links/batch\"))\n .timeout(Duration.ofSeconds(60))\n .header(\"Authorization\", \"Bearer \" + System.getenv(\"SECRET_KEY\"))\n .header(\"Content-Type\", \"application/json\")\n .POST(HttpRequest.BodyPublishers.ofString(\"{\\\"links\\\":\" + linksJson + \"}\"))\n .build();\n int status = -1;\n try {\n HttpResponse<String> res = HTTP.send(request, HttpResponse.BodyHandlers.ofString());\n status = res.statusCode();\n if (status >= 200 && status < 300) return res.body();\n if (status != 429 && status < 500) throw new IllegalStateException(\"batch refused: HTTP \" + status + \" \" + res.body());\n } catch (IOException e) {\n if (attempt >= attempts) throw e;\n }\n if (attempt >= attempts) throw new IllegalStateException(\"batch failed after \" + attempts + \" attempts (HTTP \" + status + \")\");\n Thread.sleep(1000L << (attempt - 1));\n }\n }\n}\n```\n\n```go title=\"Go: batch.go\"\npackage straitlinks\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n)\n\ntype Link struct {\n\tSlug string `json:\"slug\"`\n\tLongURL string `json:\"longUrl\"`\n\tTitle string `json:\"title,omitempty\"`\n\tTags []string `json:\"tags,omitempty\"`\n}\n\ntype Result struct {\n\tIndex int `json:\"index\"`\n\tOK bool `json:\"ok\"`\n\tStatus int `json:\"status\"`\n\tLink json.RawMessage `json:\"link,omitempty\"`\n\tError string `json:\"error,omitempty\"`\n}\n\n// CreateLinks sends links in calls of 500. Results are in input order; status 409 means the slug already exists.\nfunc CreateLinks(links []Link) ([]Result, error) {\n\tvar all []Result\n\tfor start := 0; start < len(links); start += 500 {\n\t\tend := min(start+500, len(links))\n\t\tresults, err := postWithRetry(links[start:end], 4)\n\t\tif err != nil {\n\t\t\treturn all, err\n\t\t}\n\t\tfor i := range results {\n\t\t\tresults[i].Index += start\n\t\t}\n\t\tall = append(all, results...)\n\t}\n\treturn all, nil\n}\n\nfunc postWithRetry(chunk []Link, attempts int) ([]Result, error) {\n\tpayload, err := json.Marshal(map[string][]Link{\"links\": chunk})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclient := &http.Client{Timeout: 60 * time.Second}\n\tfor attempt := 1; ; attempt++ {\n\t\treq, err := http.NewRequest(http.MethodPost, \"https://strait.link/v1/links/batch\", bytes.NewReader(payload))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+os.Getenv(\"SECRET_KEY\"))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tstatus := 0\n\t\tres, err := client.Do(req)\n\t\tif err == nil {\n\t\t\tbody, readErr := io.ReadAll(res.Body)\n\t\t\tres.Body.Close()\n\t\t\tstatus = res.StatusCode\n\t\t\tif readErr == nil && status >= 200 && status < 300 {\n\t\t\t\tvar out struct {\n\t\t\t\t\tResults []Result `json:\"results\"`\n\t\t\t\t}\n\t\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\treturn out.Results, nil\n\t\t\t}\n\t\t\tif status != 429 && status < 500 {\n\t\t\t\treturn nil, fmt.Errorf(\"batch refused: HTTP %d %s\", status, body)\n\t\t\t}\n\t\t}\n\t\tif attempt >= attempts {\n\t\t\treturn nil, fmt.Errorf(\"batch failed after %d attempts (HTTP %d, %v)\", attempts, status, err)\n\t\t}\n\t\ttime.Sleep(time.Second << (attempt - 1))\n\t}\n}\n```\n\n**You should see:** for a first run of 1,200 links, three calls and `created` holding 1,200 links, each with its `shortUrl`. Run it again and `existing` holds 1,200 slugs, with nothing created twice."
2727
+ },
2728
+ {
2729
+ "page": "Create links in bulk from your backend (batch API)",
2730
+ "group": "Server & API",
2731
+ "section": "3. Store the short links next to your data",
2732
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/#3-store-the-short-links-next-to-your-data",
2733
+ "markdown": "Each created item carries `shortUrl` (like `https://hilltop.strait.link/p-42`) and `id`. Save `shortUrl` on your product, store or partner row, so your emails, WhatsApp messages and invoices read it from your own database instead of calling the API again.\n\n**You should see:** your table filled with `shortUrl` values, and the same links in **Dashboard → Links** when you filter by the tag you set."
2734
+ },
2735
+ {
2736
+ "page": "Create links in bulk from your backend (batch API)",
2737
+ "group": "Server & API",
2738
+ "section": "4. Fix the items that failed",
2739
+ "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."
2741
+ },
2742
+ {
2743
+ "page": "Create links in bulk from your backend (batch API)",
2744
+ "group": "Server & API",
2745
+ "section": "Common errors",
2746
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/#common-errors",
2747
+ "markdown": "- **The whole call gets `400`.** `links` is missing, empty or longer than 500. Split the list into calls of 500, as in step 2.\n- **The whole call gets `401`.** The key is missing, or it's a publishable key (`st_pub_live_…`). Batch needs a secret key (`st_live_…`), from your server only.\n- **Items come back `409` on the very first run.** The slugs clash with links someone made in the dashboard, or repeat inside your list. Add a prefix per job, like `diwali-p-42`."
2748
+ },
2749
+ {
2750
+ "page": "Create links in bulk from your backend (batch API)",
2751
+ "group": "Server & API",
2752
+ "section": "Next steps",
2753
+ "url": "https://straitlink.in/docs/recipes/batch-create-links/#next-steps",
2754
+ "markdown": "- [Send campaign links via WhatsApp Business API templates](https://straitlink.in/docs/recipes/whatsapp-template-links/): put these links in approved WhatsApp templates.\n- [Batch create links (API reference)](https://straitlink.in/docs/api/batch-create-links/): every field and the full response."
2755
+ },
2756
+ {
2757
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2758
+ "group": "Server & API",
2759
+ "section": null,
2760
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/",
2761
+ "markdown": "Every delivery is an HTTPS `POST` with a JSON body and a `X-Strait-Signature` header. Anyone can send your endpoint a POST, so check the signature before you trust the body. The verifiers on this page use only each language's standard library, and every one of them is tested in our build against signatures made by our server's own signing code. For events, retries and the delivery log, see [Webhooks](https://straitlink.in/docs/webhooks/)."
2762
+ },
2763
+ {
2764
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2765
+ "group": "Server & API",
2766
+ "section": "1. Store the signing secret on your server",
2767
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#1-store-the-signing-secret-on-your-server",
2768
+ "markdown": "Copy the **signing secret** when you create the webhook in **Dashboard → Settings → Webhooks**. It's shown once. Put it in your server's environment as `WEBHOOK_SECRET`, next to your secret API key, and never in an app or a website.\n\n**You should see:** `WEBHOOK_SECRET` set in the environment your webhook handler runs in (for example `echo ${WEBHOOK_SECRET:0:5}` prints its first characters)."
2769
+ },
2770
+ {
2771
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2772
+ "group": "Server & API",
2773
+ "section": "2. Know exactly what is signed",
2774
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#2-know-exactly-what-is-signed",
2775
+ "markdown": "The header looks like this:\n\n```txt\nX-Strait-Signature: t=1800000000,v1=adf19a16865985b0891b0e7e4f6770e17d6bdd6462d897848f5adb661c4381bb\n```\n\n- `t` is the Unix time in seconds when we signed this attempt.\n- `v1` is the lowercase hex HMAC-SHA256 of the string `<t>.<raw body>`: the value of `t`, a full stop, then the exact bytes of the request body, keyed with your signing secret.\n\nSo your check is: split the header on `,` and each part on the first `=`, refuse a `t` more than 300 seconds from your clock (either way), compute the HMAC yourself and compare it with `v1` in constant time. Every retry is signed again with a fresh `t`.\n\nUse this vector to test your code before any real delivery:\n\n| Input | Value |\n|---|---|\n| Secret | `whsec` |\n| Raw body | `{\"a\":1}` |\n| `t` | `1800000000` |\n| Expected header | `t=1800000000,v1=adf19a16865985b0891b0e7e4f6770e17d6bdd6462d897848f5adb661c4381bb` |\n\n**You should see:** the same `v1` from your language's HMAC function, for example `printf '%s' '1800000000.{\"a\":1}' | openssl dgst -sha256 -hmac whsec` prints `adf19a16…381bb`."
2776
+ },
2777
+ {
2778
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2779
+ "group": "Server & API",
2780
+ "section": "3. Add the verifier for your language",
2781
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#3-add-the-verifier-for-your-language",
2782
+ "markdown": "Each function takes the secret, the raw body, the header and an optional \"now\" (so you can test it with the vector above), and returns true or false.\n\n```js title=\"Node: strait-signature.mjs\"\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nconst TOLERANCE_SECONDS = 300;\n\n/** True when `header` is a valid X-Strait-Signature for `rawBody` (a Buffer or string). */\nexport function verifyStraitSignature(secret, rawBody, header, nowSeconds = Date.now() / 1000) {\n if (!header) return false;\n const parts = new Map(header.split(',').map((p) => {\n const i = p.indexOf('=');\n return [p.slice(0, i).trim(), p.slice(i + 1).trim()];\n }));\n const t = parts.get('t') ?? '';\n const v1 = parts.get('v1') ?? '';\n if (!/^\\d+$/.test(t) || !v1) return false;\n if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;\n const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));\n const given = Buffer.from(v1);\n return expected.length === given.length && timingSafeEqual(expected, given);\n}\n```\n\n```php title=\"PHP: StraitSignature.php\"\n<?php\n\nfunction verify_strait_signature(string $secret, string $rawBody, ?string $header, ?int $now = null): bool\n{\n if ($header === null || $header === '') {\n return false;\n }\n $parts = [];\n foreach (explode(',', $header) as $part) {\n $pair = explode('=', trim($part), 2);\n if (count($pair) === 2) {\n $parts[$pair[0]] = $pair[1];\n }\n }\n $t = $parts['t'] ?? '';\n $v1 = $parts['v1'] ?? '';\n if (preg_match('/^\\d+$/D', $t) !== 1 || $v1 === '') {\n return false;\n }\n if (abs(($now ?? time()) - (int) $t) > 300) {\n return false;\n }\n $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);\n return hash_equals($expected, $v1);\n}\n```\n\n```python title=\"Python: strait_signature.py\"\nimport hashlib\nimport hmac\nimport time\nfrom typing import Optional\n\nTOLERANCE_SECONDS = 300\n\ndef verify_strait_signature(secret: str, raw_body: bytes, header: Optional[str], now: Optional[float] = None) -> bool:\n if not header:\n return False\n parts = dict(p.strip().split(\"=\", 1) for p in header.split(\",\") if \"=\" in p)\n t, v1 = parts.get(\"t\", \"\"), parts.get(\"v1\", \"\")\n if not (t.isascii() and t.isdigit()) or not v1:\n return False\n if abs((time.time() if now is None else now) - int(t)) > TOLERANCE_SECONDS:\n return False\n expected = hmac.new(secret.encode(), t.encode() + b\".\" + raw_body, hashlib.sha256).hexdigest()\n return hmac.compare_digest(expected, v1)\n```\n\n```java title=\"Java: StraitSignature.java\"\nimport java.nio.charset.StandardCharsets;\nimport java.security.GeneralSecurityException;\nimport java.security.MessageDigest;\nimport java.util.HashMap;\nimport java.util.HexFormat;\nimport java.util.Map;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\n\npublic final class StraitSignature {\n private static final long TOLERANCE_SECONDS = 300;\n\n private StraitSignature() {}\n\n public static boolean verify(String secret, byte[] rawBody, String header) {\n return verify(secret, rawBody, header, System.currentTimeMillis() / 1000);\n }\n\n public static boolean verify(String secret, byte[] rawBody, String header, long nowSeconds) {\n if (header == null || header.isEmpty()) return false;\n Map<String, String> parts = new HashMap<>();\n for (String part : header.split(\",\")) {\n String[] kv = part.trim().split(\"=\", 2);\n if (kv.length == 2) parts.put(kv[0], kv[1]);\n }\n String t = parts.getOrDefault(\"t\", \"\");\n String v1 = parts.getOrDefault(\"v1\", \"\");\n if (!t.matches(\"[0-9]{1,18}\") || v1.isEmpty()) return false;\n if (Math.abs(nowSeconds - Long.parseLong(t)) > TOLERANCE_SECONDS) return false;\n try {\n Mac mac = Mac.getInstance(\"HmacSHA256\");\n mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), \"HmacSHA256\"));\n mac.update((t + \".\").getBytes(StandardCharsets.UTF_8));\n byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);\n return MessageDigest.isEqual(expected, v1.getBytes(StandardCharsets.UTF_8));\n } catch (GeneralSecurityException e) {\n throw new IllegalStateException(e);\n }\n }\n}\n```\n\n```go title=\"Go: signature.go\"\npackage webhooks\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst toleranceSeconds = 300\n\n// VerifyStraitSignature reports whether header is a valid X-Strait-Signature for body.\nfunc VerifyStraitSignature(secret string, body []byte, header string, now time.Time) bool {\n\tvar t, v1 string\n\tfor _, part := range strings.Split(header, \",\") {\n\t\tk, v, _ := strings.Cut(strings.TrimSpace(part), \"=\")\n\t\tswitch k {\n\t\tcase \"t\":\n\t\t\tt = v\n\t\tcase \"v1\":\n\t\t\tv1 = v\n\t\t}\n\t}\n\tts, err := strconv.ParseInt(t, 10, 64)\n\tif err != nil || ts <= 0 || v1 == \"\" {\n\t\treturn false\n\t}\n\tif d := now.Unix() - ts; d > toleranceSeconds || d < -toleranceSeconds {\n\t\treturn false\n\t}\n\tmac := hmac.New(sha256.New, []byte(secret))\n\tmac.Write([]byte(t + \".\"))\n\tmac.Write(body)\n\texpected := hex.EncodeToString(mac.Sum(nil))\n\treturn hmac.Equal([]byte(expected), []byte(v1))\n}\n```\n\n**You should see:** your function return true for the vector in step 2 when you pass \"now\" as `1800000000`, and false when you pass `1800000301` or change one character of the body."
2783
+ },
2784
+ {
2785
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2786
+ "group": "Server & API",
2787
+ "section": "4. Call it with the raw body in your framework",
2788
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#4-call-it-with-the-raw-body-in-your-framework",
2789
+ "markdown": "The one mistake that breaks every verifier is hashing parsed-and-reserialised JSON instead of the bytes we sent. Each framework below hands you the raw body:\n\n```js title=\"Node: Express\"\nimport express from 'express';\nimport { verifyStraitSignature } from './strait-signature.mjs';\n\nconst app = express();\n\napp.post('/webhooks/strait', express.raw({ type: 'application/json' }), (req, res) => {\n if (!verifyStraitSignature(process.env.WEBHOOK_SECRET, req.body, req.get('X-Strait-Signature'))) {\n return res.status(401).end();\n }\n const event = JSON.parse(req.body.toString('utf8'));\n console.log(event.event, event.data);\n res.status(204).end();\n});\n```\n\n```php title=\"PHP: Laravel route\"\n<?php\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Route;\n\n// routes/api.php (no CSRF check there). Load verify_strait_signature() via Composer \"files\" autoload.\nRoute::post('/webhooks/strait', function (Request $request) {\n $ok = verify_strait_signature(\n (string) config('services.strait.webhook_secret'),\n $request->getContent(), // the raw body\n $request->header('X-Strait-Signature'),\n );\n abort_unless($ok, 401);\n $event = json_decode($request->getContent(), true);\n logger()->info('strait webhook', ['event' => $event['event']]);\n return response()->noContent();\n});\n```\n\n```python title=\"Python: Flask\"\nimport os\n\nfrom flask import Flask, abort, request\n\nfrom strait_signature import verify_strait_signature\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/strait\")\ndef strait_webhook():\n raw = request.get_data() # the exact bytes we sent\n if not verify_strait_signature(os.environ[\"WEBHOOK_SECRET\"], raw, request.headers.get(\"X-Strait-Signature\")):\n abort(401)\n event = request.get_json()\n print(event[\"event\"], event[\"data\"])\n return \"\", 204\n```\n\n```java title=\"Java: Spring Boot\"\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestHeader;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\npublic class StraitWebhookController {\n private final String secret = System.getenv(\"WEBHOOK_SECRET\");\n\n // byte[] gets the raw body; a String or an object would not match the signature.\n @PostMapping(\"/webhooks/strait\")\n public ResponseEntity<Void> receive(@RequestBody byte[] body,\n @RequestHeader(value = \"X-Strait-Signature\", required = false) String signature) {\n if (!StraitSignature.verify(secret, body, signature)) {\n return ResponseEntity.status(401).build();\n }\n // parse body with your JSON library, then handle event / data\n return ResponseEntity.noContent().build();\n }\n}\n```\n\n```go title=\"Go: net/http handler\"\npackage webhooks\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n)\n\nfunc Handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil || !VerifyStraitSignature(os.Getenv(\"WEBHOOK_SECRET\"), body, r.Header.Get(\"X-Strait-Signature\"), time.Now()) {\n\t\thttp.Error(w, \"invalid signature\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\t// json.Unmarshal(body, &event); handle event.Event / event.Data\n\tw.WriteHeader(http.StatusNoContent)\n}\n```\n\nFor Django, use `request.body`. For FastAPI, `await request.body()`. Reply within 5 seconds, then do slow work in a queue.\n\n**You should see:** a request to your endpoint with a made-up header, such as `curl -X POST https://your-server/webhooks/strait -H \"X-Strait-Signature: t=1,v1=00\" -d '{}'`, gets `401`."
2790
+ },
2791
+ {
2792
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2793
+ "group": "Server & API",
2794
+ "section": "5. Send a test event",
2795
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#5-send-a-test-event",
2796
+ "markdown": "Press **Send test event** next to the webhook in the dashboard, or call [Test a webhook](https://straitlink.in/docs/api/test-webhook/) from your server:\n\n```bash\ncurl -X POST https://strait.link/v1/webhooks/<webhook id>/test \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** `\"delivered\": true` and `\"status\": 204` in the answer, and your handler logging a `ping` event."
2797
+ },
2798
+ {
2799
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2800
+ "group": "Server & API",
2801
+ "section": "6. Ignore repeats",
2802
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#6-ignore-repeats",
2803
+ "markdown": "Delivery is at least once: a retry carries the same `X-Strait-Delivery` id (also `id` in the body) and a new signature. Store the ids you've handled, for example in a table with a unique key or Redis with `SET NX` and a 7-day expiry, and return `2xx` for a repeat without doing the work again.\n\n**You should see:** when you send your endpoint the same logged body and headers twice within 5 minutes, the event is handled once and both requests get `2xx`."
2804
+ },
2805
+ {
2806
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2807
+ "group": "Server & API",
2808
+ "section": "Common errors",
2809
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#common-errors",
2810
+ "markdown": "- **Every signature fails, even the test event.** The body was parsed before you hashed it (`express.json()`, Spring with a `String` or object, Laravel `$request->all()`). Hash the raw bytes, as in step 4. Also check the secret has no stray quotes or newline from your `.env` file.\n- **Signatures pass locally but fail in production.** The server clock is off by more than 5 minutes. Turn on NTP time sync.\n- **A proxy or gateway rejects or rewrites the request.** Some API gateways re-encode JSON or strip unknown headers. Pass `X-Strait-Signature` through and leave the body untouched."
2811
+ },
2812
+ {
2813
+ "page": "Verify webhook signatures in Node, PHP, Python, Java and Go",
2814
+ "group": "Server & API",
2815
+ "section": "Next steps",
2816
+ "url": "https://straitlink.in/docs/recipes/verify-webhook-signatures/#next-steps",
2817
+ "markdown": "- [Webhooks](https://straitlink.in/docs/webhooks/): every event and its fields, retry timing and the delivery log.\n- [Create links in bulk from your backend](https://straitlink.in/docs/recipes/batch-create-links/): the batch API, with retries that are safe to repeat."
2818
+ },
2819
+ {
2820
+ "page": "Send campaign links via WhatsApp Business API templates",
2821
+ "group": "Server & API",
2822
+ "section": null,
2823
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/",
2824
+ "markdown": "WhatsApp only lets a business start a chat with a **template**: \"Template messages are the only type of message that can be sent to WhatsApp users outside of a customer service window\", and only templates with `APPROVED` status can be sent ([Meta: Template fundamentals](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview)). This recipe puts a Strait link in the template's URL button, so one approved template can carry a different link for each campaign. Meta's API details below are quoted from Meta's docs as of October 2026; check the linked pages before you go live."
2825
+ },
2826
+ {
2827
+ "page": "Send campaign links via WhatsApp Business API templates",
2828
+ "group": "Server & API",
2829
+ "section": "1. Make the campaign link",
2830
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#1-make-the-campaign-link",
2831
+ "markdown": "Create a link whose destination carries the campaign's UTM tags. The tags tell Analytics the channel and campaign, and `title`, `description` and `imageUrl` set the preview when the link is shared in a chat:\n\n```bash\ncurl https://strait.link/v1/links \\\n -H \"Authorization: Bearer $SECRET_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"slug\": \"diwali\",\n \"longUrl\": \"https://shop.example.com/sale/diwali?utm_source=whatsapp&utm_campaign=diwali-2026\",\n \"title\": \"Diwali sale\",\n \"description\": \"Up to 40% off lights and gifts\",\n \"imageUrl\": \"https://shop.example.com/img/diwali.jpg\"\n }'\n```\n\nThen check what a tap from WhatsApp's browser does, without counting a tap:\n\n```bash\ncurl \"https://strait.link/v1/simulate?url=https%3A%2F%2Fhilltop.strait.link%2Fdiwali&ua=whatsapp-android\" \\\n -H \"Authorization: Bearer $SECRET_KEY\"\n```\n\n**You should see:** `\"shortUrl\": \"https://hilltop.strait.link/diwali\"` from the first call, and `\"inAppBrowser\": \"whatsapp\"` with your link's `slug` from the second."
2832
+ },
2833
+ {
2834
+ "page": "Send campaign links via WhatsApp Business API templates",
2835
+ "group": "Server & API",
2836
+ "section": "2. Create a template with a dynamic URL button",
2837
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#2-create-a-template-with-a-dynamic-url-button",
2838
+ "markdown": "A URL button can end in one variable. Meta's rules for URL buttons: \"Templates are limited to two URL buttons\", the URL \"Supports 1 variable, appended to the end of the URL string\", the URL is \"2000 characters maximum\" and the button label is \"25 characters maximum\" ([Meta: Template components](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/components)). Set the button URL to your link domain followed by <code>&#123;&#123;1&#125;&#125;</code>, and the slug becomes the variable:\n\n```bash\ncurl \"https://graph.facebook.com/v23.0/$WABA_ID/message_templates\" \\\n -H \"Authorization: Bearer $WHATSAPP_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"festive_sale\",\n \"language\": \"en\",\n \"category\": \"MARKETING\",\n \"parameter_format\": \"positional\",\n \"components\": [\n {\n \"type\": \"body\",\n \"text\": \"Hi &#123;&#123;1&#125;&#125;, our festive sale is live. Tap below to open it in the app.\",\n \"example\": { \"body_text\": [[\"Asha\"]] }\n },\n {\n \"type\": \"buttons\",\n \"buttons\": [\n {\n \"type\": \"url\",\n \"text\": \"Open the sale\",\n \"url\": \"https://hilltop.strait.link/&#123;&#123;1&#125;&#125;\",\n \"example\": [\"https://hilltop.strait.link/diwali\"]\n }\n ]\n }\n ]\n }'\n```\n\nThis follows Meta's [custom marketing template](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/custom-marketing-templates) request. Use your WhatsApp Business account ID as `WABA_ID` and the Graph API version your Meta app uses. You can create the same template in WhatsApp Manager instead.\n\n**You should see:** `{\"id\": \"…\", \"status\": \"PENDING\", \"category\": \"MARKETING\"}`, then the template turning **Active** (`APPROVED`) in WhatsApp Manager once Meta has reviewed it."
2839
+ },
2840
+ {
2841
+ "page": "Send campaign links via WhatsApp Business API templates",
2842
+ "group": "Server & API",
2843
+ "section": "3. Send it to customers who opted in",
2844
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#3-send-it-to-customers-who-opted-in",
2845
+ "markdown": "Meta requires opt-in before you message anyone: businesses \"are required to obtain opt-in permission\" and must name the business people are opting in to ([Meta: Get opt-in for WhatsApp](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in)). Send the template with the [Messages API](https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/message-api). The button's `parameters` hold only the slug, not the whole URL:\n\n```js title=\"Node: send-template.mjs\"\n// Node 18+. WHATSAPP_TOKEN and PHONE_NUMBER_ID come from your Meta app.\nexport async function sendCampaign(to, firstName, slug) {\n const res = await fetch(`https://graph.facebook.com/v23.0/${process.env.PHONE_NUMBER_ID}/messages`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n messaging_product: 'whatsapp',\n recipient_type: 'individual',\n to, // e.g. '919876543210'\n type: 'template',\n template: {\n name: 'festive_sale',\n language: { code: 'en' },\n components: [\n { type: 'body', parameters: [{ type: 'text', text: firstName }] },\n { type: 'button', sub_type: 'url', index: '0', parameters: [{ type: 'text', text: slug }] },\n ],\n },\n }),\n });\n if (!res.ok) throw new Error(`WhatsApp send failed: HTTP ${res.status} ${await res.text()}`);\n return res.json();\n}\n```\n\n```python title=\"Python: send_template.py\"\nimport os\n\nimport requests\n\ndef send_campaign(to: str, first_name: str, slug: str) -> dict:\n res = requests.post(\n f\"https://graph.facebook.com/v23.0/{os.environ['PHONE_NUMBER_ID']}/messages\",\n headers={\"Authorization\": f\"Bearer {os.environ['WHATSAPP_TOKEN']}\"},\n json={\n \"messaging_product\": \"whatsapp\",\n \"recipient_type\": \"individual\",\n \"to\": to, # e.g. \"919876543210\"\n \"type\": \"template\",\n \"template\": {\n \"name\": \"festive_sale\",\n \"language\": {\"code\": \"en\"},\n \"components\": [\n {\"type\": \"body\", \"parameters\": [{\"type\": \"text\", \"text\": first_name}]},\n {\"type\": \"button\", \"sub_type\": \"url\", \"index\": \"0\", \"parameters\": [{\"type\": \"text\", \"text\": slug}]},\n ],\n },\n },\n timeout=30,\n )\n res.raise_for_status()\n return res.json()\n```\n\n```php title=\"PHP: send-template.php\"\n<?php\n// Plain PHP with ext-curl. In Laravel: Http::withToken($token)->post($url, $payload).\n\nfunction sendCampaign(string $to, string $firstName, string $slug): array\n{\n $payload = [\n 'messaging_product' => 'whatsapp',\n 'recipient_type' => 'individual',\n 'to' => $to, // e.g. '919876543210'\n 'type' => 'template',\n 'template' => [\n 'name' => 'festive_sale',\n 'language' => ['code' => 'en'],\n 'components' => [\n ['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $firstName]]],\n ['type' => 'button', 'sub_type' => 'url', 'index' => '0', 'parameters' => [['type' => 'text', 'text' => $slug]]],\n ],\n ],\n ];\n $ch = curl_init('https://graph.facebook.com/v23.0/' . getenv('PHONE_NUMBER_ID') . '/messages');\n curl_setopt_array($ch, [\n CURLOPT_CUSTOMREQUEST => 'POST',\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => [\n 'Authorization: Bearer ' . getenv('WHATSAPP_TOKEN'),\n 'Content-Type: application/json',\n ],\n CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),\n ]);\n $response = curl_exec($ch);\n $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);\n if ($response === false || $status >= 300) {\n throw new RuntimeException(\"WhatsApp send failed: HTTP $status $response\");\n }\n return json_decode($response, true, 512, JSON_THROW_ON_ERROR);\n}\n```\n\n**You should see:** a response with `\"messages\": [{\"id\": \"wamid.…\", \"message_status\": \"accepted\"}]`, and on your own opted-in test phone a message whose **Open the sale** button goes to `https://hilltop.strait.link/diwali`."
2846
+ },
2847
+ {
2848
+ "page": "Send campaign links via WhatsApp Business API templates",
2849
+ "group": "Server & API",
2850
+ "section": "4. Use one link per campaign or segment",
2851
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#4-use-one-link-per-campaign-or-segment",
2852
+ "markdown": "The template stays the same; only the slug changes. For a new campaign, make a new link and send its slug. To compare segments, make one link per segment (`diwali-new`, `diwali-repeat`) with the [batch API](https://straitlink.in/docs/recipes/batch-create-links/) and a different `utm_campaign` on each. Slugs are letters, digits, `_` and `-` only, so they never need the percent-encoding Meta asks for on special characters in URL button values.\n\n**You should see:** each segment's link in **Dashboard → Links**, and the right slug in each message's button."
2853
+ },
2854
+ {
2855
+ "page": "Send campaign links via WhatsApp Business API templates",
2856
+ "group": "Server & API",
2857
+ "section": "5. Read the results",
2858
+ "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."
2860
+ },
2861
+ {
2862
+ "page": "Send campaign links via WhatsApp Business API templates",
2863
+ "group": "Server & API",
2864
+ "section": "Common errors",
2865
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#common-errors",
2866
+ "markdown": "- **The button opens `https://hilltop.strait.link/https://…`.** You sent the full URL as the button parameter. Send only the slug; the template already holds the domain.\n- **The template is rejected or the send fails with a parameter error.** The variable must be the last thing in the button URL, and every <code>&#123;&#123;1&#125;&#125;</code> needs an example value when you create the template. Check the template's status in WhatsApp Manager before sending.\n- **Taps show up as Direct, not WhatsApp.** The link's destination has no `utm_source`, and the tap opened outside WhatsApp's browser. Keep `utm_source=whatsapp` on the destination, as in step 1."
2867
+ },
2868
+ {
2869
+ "page": "Send campaign links via WhatsApp Business API templates",
2870
+ "group": "Server & API",
2871
+ "section": "Next steps",
2872
+ "url": "https://straitlink.in/docs/recipes/whatsapp-template-links/#next-steps",
2873
+ "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
+ },
2875
+ {
2876
+ "page": "Troubleshooting",
2877
+ "group": "Help",
2878
+ "section": "The link opens the browser instead of the app",
2879
+ "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."
2881
+ },
2882
+ {
2883
+ "page": "Troubleshooting",
2884
+ "group": "Help",
2885
+ "section": "Samsung Internet",
2886
+ "url": "https://straitlink.in/docs/troubleshooting/#samsung-internet",
2887
+ "markdown": "When the person taps a link on a page in Samsung Internet, it handles our hand-off like Chrome: if the app is installed it opens; if not, it goes to Google Play. When an unverified link is handed to Samsung Internet from another app (an SMS or email app), there's no tap inside the browser, and in our real-phone test (Samsung Internet 30, 3 October 2026) it went to Google Play even though the app was installed. Verified App Links avoid this, because Android opens the app before any browser runs ([details](https://straitlink.in/blog/samsung-internet-intent-link-opens-play-store/)). If Samsung Internet keeps opening links in the browser, check **Settings → Useful features → Open links in other apps** is on."
2888
+ },
2889
+ {
2890
+ "page": "Troubleshooting",
2891
+ "group": "Help",
2892
+ "section": "Firefox and Edge ask before opening the app",
2893
+ "url": "https://straitlink.in/docs/troubleshooting/#firefox-and-edge-ask-before-opening-the-app",
2894
+ "markdown": "Firefox (\"Open this link in …?\") and Edge (\"wants to open an external app\") show a confirmation before handing a link to an app. That's the browser's choice and can't be skipped. Once the person confirms, the app opens on the right screen and the tap is counted once."
2895
+ },
2896
+ {
2897
+ "page": "Troubleshooting",
2898
+ "group": "Help",
2899
+ "section": "In-app browsers (Instagram, TikTok, X, Facebook…)",
2900
+ "url": "https://straitlink.in/docs/troubleshooting/#in-app-browsers-instagram-tiktok-x-facebook",
2901
+ "markdown": "In-app browsers often don't open apps or app stores from a link on their own, so a deep link may not work inside them. We detect them and show a page made for them: on Android an **Open in app** button, and everywhere steps to open the link in the real browser, where it works normally. To skip that step, share links where possible in places that hand them to the phone's browser or app, for example Messages."
2902
+ },
2903
+ {
2904
+ "page": "Troubleshooting",
2905
+ "group": "Help",
2906
+ "section": "The deferred link didn't open the right screen after install",
2907
+ "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."
2909
+ },
2910
+ {
2911
+ "page": "Troubleshooting",
2912
+ "group": "Help",
2913
+ "section": "The app opened but `matched` is false",
2914
+ "url": "https://straitlink.in/docs/troubleshooting/#the-app-opened-but-matched-is-false",
2915
+ "markdown": "Check `reason` on the link event:\n\n| Reason | What it means |\n|---|---|\n| `not_found` | No such link in this workspace (check the publishable key and the slug) |\n| `expired` | The link's expiry has passed |\n| `password_protected` | Password links can't be opened directly in the app |\n| `no_match` | Deferred: no recent matching tap |\n| `network` | The phone couldn't reach us. The open is saved and reported later |\n| `invalid_url` | The URL couldn't be parsed |"
2916
+ },
2917
+ {
2918
+ "page": "Troubleshooting",
2919
+ "group": "Help",
2920
+ "section": "Still stuck?",
2921
+ "url": "https://straitlink.in/docs/troubleshooting/#still-stuck",
2922
+ "markdown": "Email [support@straitlink.in](mailto:support@straitlink.in) with the link, the phone model, the browser or app you tapped it in, and what happened."
2923
+ },
2924
+ {
2925
+ "page": "Test mode for deferred deep links (coming soon)",
2926
+ "group": "Help",
2927
+ "section": null,
2928
+ "url": "https://straitlink.in/docs/test-mode/",
2929
+ "markdown": ":::warn Not available yet\nTest mode is built on our side but switched off. The SDK option it needs (`testMode`) is not in any released SDK, so nothing on this page works today. The details below may still change before it ships. Today, test deferred links with a Google Play internal-testing build: [the end-to-end recipe](https://straitlink.in/docs/recipes/react-native-deferred-deep-links/) walks through it.\n:::"
2930
+ },
2931
+ {
2932
+ "page": "Test mode for deferred deep links (coming soon)",
2933
+ "group": "Help",
2934
+ "section": "Why it exists",
2935
+ "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?*"
2937
+ },
2938
+ {
2939
+ "page": "Test mode for deferred deep links (coming soon)",
2940
+ "group": "Help",
2941
+ "section": "How it will work",
2942
+ "url": "https://straitlink.in/docs/test-mode/#how-it-will-work",
2943
+ "markdown": "1. In **Dashboard → Get started**, a writer turns on test mode. That opens a 15-minute window for the workspace.\n2. On your phone, tap any of your links. WhatsApp works.\n3. Install and open your **debug** build with test mode on in the SDK.\n4. The first device that checks in during the window becomes the workspace's test device. It gets your most recent tap since the window opened, once, and the SDK hands it to your app exactly like a real deferred link: `kind: 'deferred'` in the same `onLink` callback.\n\nThe dashboard shows the result and, when nothing matched, why: no window open, a different device already used it, or no tap since you turned it on (tap your link and reopen the app)."
2944
+ },
2945
+ {
2946
+ "page": "Test mode for deferred deep links (coming soon)",
2947
+ "group": "Help",
2948
+ "section": "What it will never do",
2949
+ "url": "https://straitlink.in/docs/test-mode/#what-it-will-never-do",
2950
+ "markdown": "- **Run in release builds.** The SDK sends the test request only from debug builds; your production app never does.\n- **Change your numbers.** A test match records no install and no app open, and the tap isn't marked as matched. Analytics and billing are unchanged.\n- **Touch real matching.** Real users are matched exactly as they are today, with or without test mode.\n- **Use a hardware or advertising ID.** The test device is a random id the SDK creates once in the debug build."
2951
+ },
2952
+ {
2953
+ "page": "Test mode for deferred deep links (coming soon)",
2954
+ "group": "Help",
2955
+ "section": "When it ships",
2956
+ "url": "https://straitlink.in/docs/test-mode/#when-it-ships",
2957
+ "markdown": "When the SDKs gain the `testMode` option, this page becomes a step-by-step guide and the [changelog](https://straitlink.in/changelog/) will say so. Until then, deferred links are tested the long way: [React Native](https://straitlink.in/docs/sdks/react-native/test/#4-test-a-deferred-link-first-open-after-install), or [go-live checklist, step 3](https://straitlink.in/docs/production-checklist/#3-test-a-deferred-link-from-google-play) for any platform."
2958
+ }
2959
+ ]
2960
+ }