# Introduction starti.app SDK [#startiapp-sdk] starti.app lets you ship a **native iOS and Android app** that wraps your existing web application. Your web code runs inside a native container, and the SDK gives your JavaScript access to native device features — push notifications, biometrics, storage, authentication, and more. You write normal web code. When you need a native capability, you call an SDK method, and the native layer handles the rest. What you can do [#what-you-can-do] * **Authenticate users** with Google, Apple, Microsoft, and MitID * **Send push notifications** with topic-based subscriptions * **Access device hardware** — camera, NFC, biometrics, GPS, accelerometer * **Scan QR codes and barcodes** with the built-in scanner * **Store data** persistently on the device * **Handle in-app purchases** on iOS and Android * **Share files and text** through the native share sheet * **Control the app UI** — status bar, spinner, navigation, screen rotation * **Handle swipe gestures yourself** instead of the app's built-in back navigation Add the SDK to your page [#add-the-sdk-to-your-page] Load the SDK by adding these two tags to your HTML ``. Replace `{BRAND_NAME}` with your brand name from the starti.app manager or find the exact lines here. ```html ``` The script creates a global `startiapp` object on `window` - no `import` or `npm install` needed. Quick start [#quick-start] ```html My starti.app

You are using the native app!

Download our app for the best experience.

``` Documentation structure [#documentation-structure] Set up the SDK and learn the basics How the SDK, CDN, caching, and domain handling work Practical recipes for specific tasks Complete method and event documentation for every module Publishing your app to Apple and Google Play # AI Coding Agents AI Coding Agents [#ai-coding-agents] If you build with an AI coding agent, it will guess at the starti.app API unless you tell it where the documentation is. Every page on this site is published as plain markdown for exactly that reason. This guide shows you what to point your agent at, and what to tell it once so you never have to repeat yourself. Machine-readable documentation [#machine-readable-documentation] Three endpoints cover every use case: | URL | What it is | Rough size | | --------------------------------------------------------- | ----------------------------------------------------------------------- | ------------ | | [`/llms.txt`](https://docs.starti.app/llms.txt) | An index of every page — title, description, and a link to its markdown | \~4k tokens | | [`/llms-full.txt`](https://docs.starti.app/llms-full.txt) | Every page concatenated into one file | \~92k tokens | | `.mdx` | The raw markdown of a single page | 1–5k tokens | The `.mdx` suffix works on any page. For example, this page is at [`/sdk/ai-coding-agents.mdx`](https://docs.starti.app/sdk/ai-coding-agents.mdx), and the biometrics reference is at [`/sdk/reference/biometrics.mdx`](https://docs.starti.app/sdk/reference/biometrics.mdx). Start with `/llms.txt` and let the agent fetch individual pages on demand. That keeps the context window free for your own code. Reach for `/llms-full.txt` only when the model has a large context window and you want the whole API available at once. Tell your agent once [#tell-your-agent-once] For a single session, this is enough: ```text Use https://docs.starti.app/llms.txt as the index for the starti.app SDK. Before writing any SDK code, fetch the .mdx URL of the relevant page and follow it — do not guess method names or option shapes. ``` Make it permanent [#make-it-permanent] Most agents read a project file on every run. Drop the block below into the right file for your tool and the instruction sticks. Add it to `CLAUDE.md` in your project root. Add it to `AGENTS.md` in your project root. This is the convention used by OpenAI Codex, Gemini CLI, Kimi, and a growing number of other agents — several of them also read `CLAUDE.md` as a fallback. Add it to `.cursor/rules/starti-app.mdc` in your project root. ```markdown ## starti.app SDK This project runs inside a starti.app container — a native iOS/Android app that wraps our web application and exposes device features to JavaScript. Documentation: - Index of every page: https://docs.starti.app/llms.txt - Any page as raw markdown: append `.mdx` to its URL, e.g. https://docs.starti.app/sdk/reference/biometrics.mdx - Read the reference page for a module before calling its methods. Do not guess method names, parameters, or option shapes. Rules for SDK code: - The SDK is loaded from the CDN with a ` ``` Use the global startiapp object [#use-the-global-startiapp-object] Once the npm package is installed, TypeScript recognizes the global `startiapp` object automatically. The package declares it on `window`: ```typescript // No import needed — the types are globally available after installing the package. // The global `startiapp` object is typed as `StartiappClass`. startiapp.addEventListener("ready", async () => { startiapp.initialize(); const version = await startiapp.App.version(); // type: string const deviceId = await startiapp.App.deviceId(); // type: string const volume = await startiapp.Media.getVolume(); // type: number }); ``` Your editor will provide autocompletion for all modules (`App`, `Auth`, `QrScanner`, `Media`, etc.) and their methods, parameters, and return types. TypeScript configuration [#typescript-configuration] No special `tsconfig.json` changes are needed. The package uses the standard `types` field in `package.json` to point to its declaration files. As long as the package is installed, the types are picked up automatically. If you have a restrictive `types` array in your `tsconfig.json`, make sure it does not exclude the `starti.app` package. Framework examples [#framework-examples] React [#react] ```tsx import { useEffect, useState } from "react"; function AppInfo() { const [version, setVersion] = useState(""); useEffect(() => { async function init() { try { await startiapp.initialize(); setVersion(await startiapp.App.version()); } catch { // Not running in starti.app container } } init(); }, []); return

App version: {version || "unknown"}

; } ``` Vue 3 [#vue-3] ```vue ``` Svelte [#svelte] ```svelte

App version: {version || "unknown"}

``` Available types [#available-types] The package exports types for all SDK modules. Key types include: | Type | Description | | ------------------------- | ---------------------------------------------------------- | | `StartiappClass` | The main SDK class (type of the global `startiapp` object) | | `InitializeParams` | Options for `startiapp.initialize()` | | `SetStatusBarOptions` | Status bar configuration | | `SpinnerOptions` | Navigation spinner configuration | | `SafeAreaSideOptions` | Safe area configuration | | `AdvancedSafeAreaOptions` | Per-side safe area overrides | Each module also exports its own types (e.g. `QrScannerOptions`, `VolumeChangedEventArgs`). These are available through the type system automatically when you access module methods. Checking for the container at compile time [#checking-for-the-container-at-compile-time] Since `startiapp` is always declared globally by the types package, TypeScript will not warn you if you call SDK methods outside the container. Use `isRunningInApp()` at runtime to guard native calls: ```typescript if (startiapp.isRunningInApp()) { // Safe to call native methods const deviceId = await startiapp.App.deviceId(); } else { // Running in a regular browser } ``` Or use `initialize()` with try/catch as shown in the [Setup and the Basics](/sdk/getting-started/setup-and-the-basics) guide. # Adding Authentication Adding Authentication [#adding-authentication] By default, username/password login in your app works exactly the same way as it does on your website — no extra SDK work is needed. If you want to add biometric login (Face ID / fingerprint), see [Biometric Login](/sdk/how-to/biometric-login). This page covers **social and third-party sign-in** — Google, Apple, Microsoft, MitID and more. We currently support the providers listed below, and we continuously add new ones as the need arises. Supported providers [#supported-providers] The SDK supports the following built-in providers: | Provider | `providerName` value | | --------------- | ------------------------ | | Google | `"google"` | | Apple | `"apple"` | | Microsoft | `"microsoft"` | | MitID (Denmark) | `"signaturgruppenmitid"` | You can also pass any custom provider name as a string if you have configured one in the manager. Sign in with Google [#sign-in-with-google] The `signIn()` method opens the native authentication flow for the requested provider. It returns a Promise that resolves with the authentication result. ```javascript const result = await startiapp.Auth.signIn("google"); ``` That single line opens a native Google sign-in dialog. The user authenticates with their Google account, and the SDK returns the result to your code. Handle the authentication result [#handle-the-authentication-result] Every result object has an `isSuccess` boolean. When the sign-in succeeds, you get an authorization code and related fields that your backend can exchange for tokens. ```javascript const result = await startiapp.Auth.signIn("google"); if (result.isSuccess) { console.log("Authorization code:", result.authorizationCode); console.log("Code verifier:", result.codeVerifier); console.log("Redirect URI:", result.redirectUri); // Send these to your backend to exchange for access/refresh tokens await sendToBackend({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, }); } else { console.log("Sign-in failed:", result.errorMessage); } ``` The result for a successful sign-in (for Google, Microsoft, MitID) contains: | Property | Type | Description | | ------------------- | ------------------------ | --------------------------------------------------- | | `isSuccess` | `true` | Indicates success | | `providerName` | `string` | The provider that was used | | `authorizationCode` | `string` | OAuth authorization code to exchange on your server | | `codeVerifier` | `string` | PKCE code verifier for the token exchange | | `redirectUri` | `string` | The redirect URI used during the flow | | `additionalClaims` | `Record` | Extra claims returned by the provider | A failed result contains: | Property | Type | Description | | -------------- | -------- | ---------------------------------- | | `isSuccess` | `false` | Indicates failure | | `providerName` | `string` | The provider that was used | | `errorMessage` | `string` | A human-readable error description | The `authorizationCode` is a one-time-use code. Exchange it on your server immediately and do not store it on the client. Check for an existing session [#check-for-an-existing-session] Use `getCurrentSession()` to see if the user already has an active session. This is useful on page load to skip the sign-in screen. ```javascript const session = await startiapp.Auth.getCurrentSession(); if (session) { console.log("User is already signed in."); console.log("Provider:", session.providerName); } else { console.log("No active session -- show sign-in screen."); } ``` There is also a convenience method `isAuthenticated()` that returns a simple boolean. ```javascript const loggedIn = await startiapp.Auth.isAuthenticated(); if (loggedIn) { // proceed to the app } else { // show the login page } ``` Both `getCurrentSession()` and `isAuthenticated()` are asynchronous because they query the native bridge. Sign out [#sign-out] Call `signOut()` to end the user's session. ```javascript const success = await startiapp.Auth.signOut(); if (success) { console.log("User has been signed out."); // Redirect to the login page } else { console.log("Sign-out failed."); } ``` Apple sign-in specifics [#apple-sign-in-specifics] Apple Sign In returns a different result shape compared to other providers. Instead of `authorizationCode` + `codeVerifier`, Apple returns an `identity` object with user details. ```javascript const result = await startiapp.Auth.signIn("apple"); if (result.isSuccess) { console.log("User ID:", result.identity.userId); console.log("Email:", result.identity.email); console.log("Name:", result.identity.name); console.log("ID Token:", result.identity.idToken); console.log("Authorization code:", result.authorizationCode); console.log("Redirect URI:", result.redirectUri); } ``` The Apple result contains: | Property | Type | Description | | ------------------- | ---------------- | --------------------------------------- | | `identity.userId` | `string` | Apple's stable user identifier | | `identity.email` | `string` | The user's email address | | `identity.name` | `string \| null` | The user's name (only on first sign-in) | | `identity.idToken` | `string` | A signed JWT from Apple | | `authorizationCode` | `string` | OAuth authorization code | | `redirectUri` | `string` | The redirect URI used during the flow | Apple only provides the user's name on the **very first** sign-in. On subsequent sign-ins, `identity.name` is `null`. Make sure your backend stores the name during the first authentication. MitID sign-in with options [#mitid-sign-in-with-options] MitID (via Signaturgruppen) supports additional options. You can require a CPR number (Danish social security number) or specify custom scopes. ```javascript // Request CPR number const result = await startiapp.Auth.signIn("signaturgruppenmitid", { scope: "openid mitid ssn", }); if (result.isSuccess) { console.log("CPR:", result.additionalClaims["cpr"]); } ``` During development, MitID uses the pre-production environment (`pp.netseidbroker.dk`). You can create test identities at [pp.mitid.dk/test-tool/frontend/#/create-identity](https://pp.mitid.dk/test-tool/frontend/#/create-identity). Listening for authentication events [#listening-for-authentication-events] The Auth integration dispatches an `authenticationCompleted` event when sign-in finishes. This can be useful if you want a centralized authentication handler. ```javascript startiapp.Auth.addEventListener("authenticationCompleted", function (event) { const result = event.detail; if (result.isSuccess) { console.log("Authenticated with:", result.providerName); } }); ``` Complete working example [#complete-working-example] Here is a full example combining all the steps into a simple authentication flow. ```javascript async function main() { if (!startiapp.isRunningInApp()) { console.log("Authentication requires the native app."); return; } await startiapp.initialize(); // Check for existing session const existingSession = await startiapp.Auth.getCurrentSession(); if (existingSession) { console.log("Welcome back! Provider:", existingSession.providerName); showApp(); return; } // No session -- sign in console.log("No session found. Starting sign-in..."); const result = await startiapp.Auth.signIn("google"); if (result.isSuccess) { console.log("Signed in successfully!"); console.log("Authorization code:", result.authorizationCode); // Exchange the authorization code on your backend await fetch("/api/auth/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, provider: result.providerName, }), }); showApp(); } else { console.error("Sign-in failed:", result.errorMessage); showError(result.errorMessage); } } function showApp() { document.getElementById("login-screen").style.display = "none"; document.getElementById("app-content").style.display = "block"; } function showError(message) { document.getElementById("error-text").textContent = message; } // Wire up the sign-out button document.getElementById("sign-out-btn").addEventListener("click", async function () { const success = await startiapp.Auth.signOut(); if (success) { window.location.reload(); } }); main(); ``` # Push Notifications Push Notifications [#push-notifications] starti.app offers several approaches to push notifications. Pick the one that fits your needs — they can also be combined. | Approach | What you get | Code required? | | ---------------- | ------------------------------------------------- | -------------------------------------------------------------- | | **Manager** | Broadcast to all users or topic groups | No — configured entirely in the starti.app Manager | | **Webhooks** | Per-user notifications triggered by your platform | One SDK call (`registerId`) + webhook configuration in Manager | | **Per-user API** | Target individual users from your own backend | One SDK call (`registerId`) + API calls from your server | | **Advanced** | Full control over FCM tokens and Firebase | You manage tokens and talk to Firebase directly | Set up push notifications [#set-up-push-notifications] This approach requires no code changes in your web app. Everything is configured in the [starti.app Manager](https://manager.starti.app). Add a push notification page to your Introflow [#add-a-push-notification-page-to-your-introflow] In the Manager, open your app's **Introflow** and add a push notification consent page. When a user goes through the Introflow and accepts, they are automatically subscribed to receive push notifications. Send broadcasts from the Manager [#send-broadcasts-from-the-manager] Once users have opted in, you can send push notifications to all subscribed users directly from the Manager. No API calls or code needed. Add topics for targeted broadcasts [#add-topics-for-targeted-broadcasts] If you want users to choose **which types** of notifications they receive, add topic categories in the Manager. Users will see the available topics in the Introflow and can select the ones they are interested in. When sending a notification from the Manager, you can choose to send to all subscribers or only to users subscribed to specific topics. The Manager shows subscriber counts for each topic so you can see how many users will receive the message. Webhooks let you send per-user notifications without writing any backend code. Your existing platform (for example, an eCommerce system) calls a webhook URL, and the Manager takes care of delivering the notification to the right user. Register the user in the frontend [#register-the-user-in-the-frontend] Call `registerId()` when the user logs in so that starti.app knows who is on which device. ```javascript await startiapp.User.registerId("your-user-id"); ``` When the user logs out, unregister them: ```javascript await startiapp.User.unregisterId(); ``` Create a webhook in the Manager [#create-a-webhook-in-the-manager] In the Manager, create a new webhook. This gives you a URL that your platform can call when something happens — for example, when an order changes status. Add the webhook URL to your platform's notification settings (most eCommerce platforms, CRM systems, and similar tools support outgoing webhooks). Set up rules in the Manager [#set-up-rules-in-the-manager] In the Manager, configure rules for what should happen when the webhook is called. For example, you can set up a rule that sends a push notification saying "Your order has been shipped" when an order gets the status "shipped". The webhook payload includes the user ID, so the Manager knows exactly which user to notify — and starti.app delivers the message to all their devices. You can also choose to send to a topic instead of an individual user, which is useful for broadcasts triggered by external events (for example, notifying all users subscribed to "offers" when a new promotion is created). This approach is ideal when you want per-user notifications but don't want to build backend logic for sending them. Your platform triggers the webhook, and the Manager handles the rest. When you need to send notifications to individual users from your own backend — and want full control over when and what to send — use the per-user API approach. Request permission [#request-permission] Before sending push notifications, the user must grant permission. Call `requestAccess()` to prompt the user. ```javascript const granted = await startiapp.PushNotification.requestAccess(); if (granted) { console.log("Push notifications enabled!"); } else { console.log("User denied push notification access."); } ``` On iOS, this shows the standard system permission dialog. On Android 13+, it shows the runtime permission dialog. On older Android versions, permission is granted by default. If you already handle permission through the Manager Introflow, you can skip this step — users who accepted there already have permission. Register the user [#register-the-user] After the user logs in, call `registerId()` with the ID you use to identify the user in your system. ```javascript await startiapp.User.registerId("your-user-id"); console.log("User registered for push notifications!"); ``` That's it. starti.app now knows which user is on which device. If the same user logs in on multiple devices (for example, an iPhone and an iPad), notifications sent to that user ID are delivered to all their devices. You can now send push notifications to this user through the [REST API](/sdk/rest-api/push-notifications). When the user logs out, unregister them so the device is no longer associated with their account: ```javascript await startiapp.User.unregisterId(); ``` Use this approach when you need direct control over FCM tokens — for example, if you have a custom backend that sends push notifications through Firebase Cloud Messaging directly rather than through the starti.app API. Request permission [#request-permission-1] ```javascript const granted = await startiapp.PushNotification.requestAccess(); if (granted) { console.log("Push notifications enabled!"); } else { console.log("User denied push notification access."); } ``` Get the FCM token [#get-the-fcm-token] The FCM token uniquely identifies this device for push notifications. Send this token to your backend so your server can target this specific device. ```javascript const token = await startiapp.PushNotification.getToken(); console.log("FCM token:", token); // Send it to your server await fetch("/api/push/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fcmToken: token }), }); ``` The FCM token can change over time (for example, when the user reinstalls the app or clears app data). Always handle token refreshes to keep your server up to date. See the next step. Handle token refresh [#handle-token-refresh] FCM tokens can change. Listen for the `tokenRefreshed` event and update your server whenever a new token arrives. ```javascript startiapp.PushNotification.addEventListener("tokenRefreshed", function (event) { const newToken = event.detail; console.log("FCM token refreshed:", newToken); // Update your server with the new token fetch("/api/push/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fcmToken: newToken }), }); }); ``` In practice, none of our customers use this approach — the additional setup and manual token management is rarely worth it. The other approaches cover the vast majority of use cases. But the option is here if you need it. Work with topics [#work-with-topics] Topics let you segment your notifications so you can send messages to groups of users rather than individuals. Topics can be configured in the Manager or managed programmatically with the SDK — regardless of which approach you use for sending notifications. Get available topics [#get-available-topics] Fetch the list of topics configured for your brand. ```javascript const topics = await startiapp.PushNotification.getTopics(); topics.forEach(function (topic) { console.log( topic.name + " (" + topic.topic + ") - subscribed: " + topic.subscribed, ); }); ``` Each `Topic` object has: | Property | Type | Description | | ------------ | --------- | ------------------------------------------ | | `topic` | `string` | The topic identifier used internally | | `name` | `string` | A human-readable display name | | `subscribed` | `boolean` | Whether the device is currently subscribed | Subscribe to topics [#subscribe-to-topics] ```javascript const result = await startiapp.PushNotification.subscribeToTopics(["news", "offers"]); console.log("Subscribed to:", result); ``` `subscribeToTopics()` automatically requests push notification permission if it has not been granted yet. If you subscribe to a topic that does not already exist, it is created automatically. Unsubscribe from topics [#unsubscribe-from-topics] ```javascript await startiapp.PushNotification.unsubscribeFromTopics(["offers"]); console.log("Unsubscribed from offers."); ``` Subscribe/unsubscribe using Topic objects [#subscribeunsubscribe-using-topic-objects] The `Topic` objects returned by `getTopics()` also have convenience methods. ```javascript const topics = await startiapp.PushNotification.getTopics(); // Subscribe to the first topic topics[0].subscribe(); // Unsubscribe from the second topic topics[1].unsubscribe(); ``` Set badge count [#set-badge-count] Set the app icon badge number to indicate unread notifications. Pass `0` to clear the badge. ```javascript // Show 5 unread notifications startiapp.PushNotification.setBadgeCount(5); // Clear the badge startiapp.PushNotification.setBadgeCount(0); ``` On Android, badge support depends on the device manufacturer's launcher. Most modern launchers support it, but behavior may vary. Check permission status [#check-permission-status] You can check whether the user has already granted push notification permission without prompting them. ```javascript const status = await startiapp.PushNotification.checkAccess(); console.log("Permission granted:", status.granted); ``` This is useful on page load to decide whether to show an "Enable notifications" button or go straight to your push notification setup. You don't need to report permission changes yourself. The SDK automatically syncs the current permission with the backend on each app launch and whenever the app returns to the foreground. So if a user turns notifications off (or back on) in their device settings, the backend is updated without a manual `requestAccess()` call. Listen for foreground notifications [#listen-for-foreground-notifications] When a notification arrives while the app is in the foreground, the SDK dispatches a `notificationReceived` event with the notification's title and body. ```javascript startiapp.PushNotification.addEventListener( "notificationReceived", function (event) { var title = event.detail.title; var body = event.detail.body; console.log("Notification received:", title, body); }, ); ``` Push notifications are displayed to the user by the operating system — whether the app is in the foreground or background, and regardless of whether you listen for this event. The one exception is a message sent with [`silentInForeground`](/sdk/rest-api/push-notifications#request-body), which the system holds back while the app is open so your page can present it itself; this event still fires. Otherwise the `notificationReceived` event is only for cases where your code needs to react to a notification while the app is open. Most apps do not need this. If you need real-time data updates from your backend, consider using WebSockets or server-sent events instead. Complete working examples [#complete-working-examples] ```javascript async function main() { if (!startiapp.isRunningInApp()) return; await startiapp.initialize(); // Request permission const granted = await startiapp.PushNotification.requestAccess(); if (!granted) { console.log("User denied notifications."); showOptInBanner(); return; } // Register the user — this handles FCM token management automatically const userId = getCurrentUserId(); // your own function if (userId) { await startiapp.User.registerId(userId); } // Optionally, load and display topics const topics = await startiapp.PushNotification.getTopics(); renderTopicList(topics); } main(); ``` ```javascript async function main() { if (!startiapp.isRunningInApp()) return; await startiapp.initialize(); // Request permission const granted = await startiapp.PushNotification.requestAccess(); if (!granted) { console.log("User denied notifications."); showOptInBanner(); return; } // Get and send the FCM token to your server const token = await startiapp.PushNotification.getToken(); await registerTokenOnServer(token); // Listen for foreground notifications startiapp.PushNotification.addEventListener( "notificationReceived", function (event) { showNotificationBanner(event.detail.title, event.detail.body); }, ); // Handle token refresh startiapp.PushNotification.addEventListener("tokenRefreshed", function (event) { registerTokenOnServer(event.detail); }); // Optionally, load and display topics const topics = await startiapp.PushNotification.getTopics(); renderTopicList(topics); } async function registerTokenOnServer(token) { await fetch("/api/push/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fcmToken: token }), }); } main(); ``` # Setup and the Basics Setup and the Basics [#setup-and-the-basics] This page covers adding the SDK to your page, initializing it, and the basics you need to know. For a full introduction to what starti.app is, see the [Introduction](/). To get up and running, you only need to do two things: add the SDK to your page and call `initialize()`. Add the SDK to your page [#add-the-sdk-to-your-page] Add the SDK script and stylesheet to your HTML ``. Replace `{BRAND_NAME}` with your brand name from the starti.app manager. ```html ``` The script creates a global `startiapp` object on `window` - no `import` or `npm install` needed. The CSS file provides utility classes and CSS custom properties for safe-area insets (see below). You can also simply copy the snippet directly from the starti.app Manager. Initialize the SDK [#initialize-the-sdk] Call `initialize()` to activate the SDK. Wrapping it in a try/catch lets your site work normally in a regular browser while activating native features only when the container is present. ```javascript try { await startiapp.initialize(); console.log("starti.app is ready!"); } catch (error) { console.log("Not running inside a starti.app container."); } ``` The app's splash screen stays visible until `initialize()` is called. This gives you a chance to load images and other assets before the transition from splash screen to your content. Once `initialize()` is called, the splash screen fades out automatically. You can also pass options to `initialize()` to configure the status bar, spinner, drag behavior, and more. See the [App API reference](/sdk/reference/app#initializeparams) for the full list of options. **Configure these in the Manager where you can.** Every option `initialize()` accepts can also be set for your brand under [Initialization settings](https://docs.starti.app/en/academy/manager/development-setup/initialization-settings) in the [starti.app Manager](https://manager.starti.app/initialization-settings) — it then applies everywhere in your app with no code, and changing it is a publish rather than a new release of your website. Pass options in code only for values that can only be decided at runtime; what you pass wins property by property, so the rest of the brand's configuration is left alone. Other ways to initialize [#other-ways-to-initialize] The try/catch pattern above is the simplest approach, but there are two other options depending on your setup: **Using the `ready` event** — useful if you want to wait for the native bridge before initializing: ```javascript startiapp.addEventListener("ready", async function () { startiapp.initialize(); console.log("starti.app is ready!"); }); ``` You can register multiple listeners, and if you register one **after** initialization has already completed, the callback fires immediately — you never miss the event. **Using `isRunningInApp()` first** — useful when you only want to initialize inside the app: ```javascript if (startiapp.isRunningInApp()) { await startiapp.initialize(); console.log("Native features are available."); } else { console.log("Running in a normal browser - native features are disabled."); } ``` `initialize()` should only be called **once** — typically early in your app's lifecycle. If your app is a single-page application, call it at the top level, not on every route change. Detect if running in the app [#detect-if-running-in-the-app] The synchronous helper `isRunningInApp()` returns `true` when your code is running inside the starti.app container. This is useful when your JavaScript logic needs to branch depending on the environment — for example, choosing between a native scanner and a web-based fallback. ```javascript if (startiapp.isRunningInApp()) { // Use the native QR scanner const result = await startiapp.QRScanner.scan(); } else { // Fall back to a web-based scanner library } ``` If you just need to show or hide UI elements, use the [CSS utility classes](#css-utility-classes) instead — no JavaScript needed. If you need to detect the app **server-side**, check whether the `User-Agent` HTTP header contains `starti.app`. Preview in a browser [#preview-in-a-browser] You don't need a phone to test your app. Chrome DevTools lets you add a custom device that mimics the native app's screen size and user agent, so the SDK behaves as if it's running inside the starti.app container. This is handy for quickly iterating on layout and styling. See the [Simulate the App in a Browser](/sdk/how-to/simulate-in-browser) guide for step-by-step instructions. Handling errors [#handling-errors] The SDK logs warnings to the console when something goes wrong (for example, calling a native API outside the container). You can also listen for error events globally. ```javascript startiapp.addEventListener("error", function (event) { console.error("starti.app error:", event.detail); }); ``` This is useful for logging and diagnostics - it catches errors dispatched by the native bridge. Listening for events [#listening-for-events] The app communicates with your website through events. Each SDK module has its own events that you can listen for using `addEventListener`. The pattern is the same across all modules: ```javascript startiapp.PushNotification.addEventListener("tokenRefreshed", (event) => { console.log("New push token:", event.detail); }); startiapp.App.addEventListener("appInForeground", () => { // Refresh data when the user returns to the app }); ``` Event callbacks receive an `event` object where `event.detail` contains the event data. Some events (like `appInForeground`) have no data — the callback is simply invoked when the event occurs. You have already seen two global events above: `ready` (fired when the native bridge is available) and `error` (fired when the SDK encounters an issue). Individual modules fire their own events — see each module's [API reference](/sdk/reference/app) for the full list. CSS utility classes [#css-utility-classes] The SDK automatically adds a `startiapp` attribute to `` when running inside the app. You can use this attribute as a CSS selector to apply app-specific styles: ```css /* Change background color only inside the app */ body[startiapp] { background-color: #f5f5f5; } /* Hide a navigation bar inside the app */ body[startiapp] .browser-nav { display: none; } ``` The SDK also injects utility CSS classes you can use to show or hide content based on the environment: | Class | Behavior | | --------------------------- | ---------------------------------- | | `startiapp-show-in-app` | Visible only inside the native app | | `startiapp-hide-in-app` | Hidden inside the native app | | `startiapp-show-in-browser` | Visible only in a regular browser | | `startiapp-hide-in-browser` | Hidden in a regular browser | ```html

You are using the native app!

Download our app for the best experience.

``` The keyboard-open attribute [#the-keyboard-open-attribute] Inside the app the SDK also puts a `startiapp-keyboard-open` attribute on `` for as long as the software keyboard is up, and takes it off again when the keyboard goes. It is the CSS counterpart of the [`keyboardOpened` and `keyboardClosed`](/sdk/reference/app#keyboardopened) events — style with the attribute, and listen for the events only when something has to *do* something (scroll a list back to the bottom, refetch a measurement). ```css /* Get a floating action button out of the way while the user is typing */ body[startiapp-keyboard-open] .fab { display: none; } /* Give a chat composer a tighter footer when the keyboard has the bottom of the screen */ body[startiapp-keyboard-open] .composer { padding-block-end: 0; } ``` The app has already resized the web view to the room the keyboard leaves, so your layout has reflowed before the attribute appears — use it to change what is shown, not to make room. The attribute only ever appears inside the app, and only on app shells new enough to send the keyboard events, so treat it as an enhancement. CSS custom properties (safe-area insets) [#css-custom-properties-safe-area-insets] The SDK provides CSS custom properties for device safe-area insets. Use these to avoid content being hidden behind notches, status bars, or home indicators. | Property | Description | | -------------------------- | ---------------------------------- | | `--startiapp-inset-top` | Top safe area (status bar / notch) | | `--startiapp-inset-right` | Right safe area | | `--startiapp-inset-bottom` | Bottom safe area (home indicator) | | `--startiapp-inset-left` | Left safe area | ```css body { padding-top: var(--startiapp-inset-top, 0px); padding-bottom: var(--startiapp-inset-bottom, 0px); padding-left: var(--startiapp-inset-left, 0px); padding-right: var(--startiapp-inset-right, 0px); } ``` Always provide a fallback value (e.g. `0px`) when using these properties so your page looks correct in a regular browser where the variables are not set. Read device information [#read-device-information] Once the SDK is initialized, you can query the device platform, device ID, and brand ID. **Platform** is a synchronous property that returns `"ios"`, `"android"`, or `"web"`. ```javascript const platform = startiapp.App.platform; console.log("Platform:", platform); // "ios" or "android" ``` Avoid building platform-dependent functionality. Your app should work the same way on both iOS and Android. In rare cases where you need to differentiate, `platform` is available — but treat it as an exception, not the norm. **Device ID** and **Brand ID** are asynchronous because they call into the native bridge. The device ID is unique to the current app installation — if the user uninstalls and reinstalls the app, a new ID is generated. ```javascript const deviceId = await startiapp.App.deviceId(); console.log("Device ID:", deviceId); const brandId = await startiapp.App.brandId(); console.log("Brand ID:", brandId); ``` You can also read the app version. ```javascript const version = await startiapp.App.version(); console.log("App version:", version); ``` `deviceId()` and `brandId()` cache their results after the first call, so calling them multiple times is cheap. Complete working example [#complete-working-example] Here is a complete HTML page that ties all the steps together. Save it as an HTML file, replace `{BRAND_NAME}` with your brand name, and test it on a real device. ```html starti.app Getting Started

Device Info

Open this page inside the starti.app native container to see device info.

``` If you use a framework like React, Vue, or Svelte, the pattern is the same: load the SDK via script tag in your `index.html`, then use the global `startiapp` object in your components. Call `startiapp.initialize()` early in your app's lifecycle (for example, inside a React `useEffect` or a Vue `onMounted` hook), and use the `ready` event to gate native feature calls. # Storage and Data Storage and Data [#storage-and-data] This page walks you through using the starti.app storage APIs to persist data on the device. You will learn the difference between AppStorage and web localStorage, how to store and retrieve values, and how to manage storage safely. AppStorage vs web localStorage [#appstorage-vs-web-localstorage] Your web app can already use `window.localStorage`, but it has limitations inside a native container: | Feature | `window.localStorage` | `startiapp.Storage.app` (AppStorage) | | --------------------- | ---------------------------------------------- | -------------------------------------------- | | Persistence | Can be cleared by the OS under memory pressure | Stored natively — survives OS cache clearing | | Size limit | \~5 MB (browser-dependent) | Up to **50 MB** total, **256 KB** per value | | Shared across domains | No (origin-scoped) | Yes — shared across all domains in the app | | Async API | No (synchronous) | Yes (all methods return Promises) | AppStorage is the recommended choice when you need reliable persistence. It uses the native platform's storage system when the bridge is available, and falls back to localStorage in older app versions or during development in a regular browser. You can check which storage backend is active by calling `startiapp.Storage.app.isUsingNativeBridge()`. It returns `true` when native storage is in use, and `false` when falling back to localStorage. Store a value [#store-a-value] Use `setItem(key, value)` to persist a string. Both the key and value must be strings. ```javascript await startiapp.Storage.app.setItem("user-preference", "dark-mode"); ``` To store objects or arrays, serialize them to JSON first. ```javascript const settings = { theme: "dark", fontSize: 16, notifications: true }; await startiapp.Storage.app.setItem("settings", JSON.stringify(settings)); ``` Retrieve a value [#retrieve-a-value] Use `getItem(key)` to read a stored value. It returns the string value, or `null` if the key does not exist. ```javascript const preference = await startiapp.Storage.app.getItem("user-preference"); if (preference !== null) { console.log("Preference:", preference); } else { console.log("No preference saved yet."); } ``` For JSON values, parse them after retrieval. ```javascript const raw = await startiapp.Storage.app.getItem("settings"); if (raw) { const settings = JSON.parse(raw); console.log("Theme:", settings.theme); } ``` Remove a value [#remove-a-value] Use `removeItem(key)` to delete a single entry. ```javascript await startiapp.Storage.app.removeItem("user-preference"); ``` Clear all AppStorage data [#clear-all-appstorage-data] Use `clear()` to remove **all** entries stored through AppStorage. ```javascript await startiapp.Storage.app.clear(); console.log("All AppStorage data cleared."); ``` `clear()` removes all AppStorage data for the entire app, not just the current page. Use this with caution. Size limits [#size-limits] AppStorage has the following limits: | Limit | Value | | --------------------- | -------------------- | | Maximum value size | **256 KB** per value | | Maximum total storage | **50 MB** | If you need to store larger data (images, files), consider uploading to your server and storing only a URL or reference in AppStorage. These limits apply to the native storage backend. If the SDK falls back to localStorage, the browser's own limits apply instead (typically around 5 MB). Security note [#security-note] AppStorage data is **not encrypted**. It is stored in plain text on the device. Do not use AppStorage for: * Passwords or secret keys * Authentication tokens (use the Auth integration's session management instead) * Personally identifiable information that requires encryption at rest AppStorage is well suited for preferences, feature flags, cached display data, and other non-sensitive values. Clearing all web data [#clearing-all-web-data] The `Storage` integration also provides `clearWebData()`, which goes further than `clear()`. It wipes the web data the app has accumulated on both platforms — cookies, localStorage, session storage and the HTTP cache. See [Caching](/sdk/explanation/caching) for the platform detail and the behaviour of older app versions. ```javascript await startiapp.Storage.clearWebData(); console.log("All web data has been cleared."); ``` `clearWebData()` is a destructive operation. The user is logged out of all web sessions, cookies are deleted and cached data is gone. Use it for "reset app" features, not for routine storage management. In app versions 4.1.224 and earlier it does not clear the HTTP cache or cookies on Android — see [Caching](/sdk/explanation/caching). Complete working example [#complete-working-example] Here is a full example that implements a simple settings panel backed by AppStorage. ```javascript const SETTINGS_KEY = "app-settings"; const defaultSettings = { theme: "light", fontSize: 14, notifications: true, }; async function main() { if (!startiapp.isRunningInApp()) { console.log("Running in browser -- AppStorage will use localStorage fallback."); } try { await startiapp.initialize(); } catch (e) { // Continue anyway -- AppStorage falls back to localStorage } // Load saved settings or use defaults const settings = await loadSettings(); console.log("Current settings:", settings); // Update a setting settings.theme = "dark"; await saveSettings(settings); console.log("Settings saved."); // Verify the save const reloaded = await loadSettings(); console.log("Reloaded settings:", reloaded); } async function loadSettings() { const raw = await startiapp.Storage.app.getItem(SETTINGS_KEY); if (raw) { try { const parsed = JSON.parse(raw); return Object.assign({}, defaultSettings, parsed); } catch (e) { console.warn("Corrupt settings data -- using defaults."); } } return Object.assign({}, defaultSettings); } async function saveSettings(settings) { await startiapp.Storage.app.setItem(SETTINGS_KEY, JSON.stringify(settings)); } // Wire up a "Reset all data" button const resetBtn = document.getElementById("reset-btn"); if (resetBtn) { resetBtn.addEventListener("click", async function () { if (confirm("This will clear all app data. Continue?")) { await startiapp.Storage.app.clear(); console.log("All AppStorage data cleared."); window.location.reload(); } }); } main(); ``` # Biometric Login Biometric Login [#biometric-login] Use biometrics (Face ID / fingerprint) to let users log in without typing their credentials each time. The SDK can save, retrieve, and manage username/password pairs secured by biometric authentication. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * The device supports biometric authentication (Face ID or fingerprint) Option A: Manual credential flow [#option-a-manual-credential-flow] Save and retrieve credentials directly with biometric protection. Save credentials [#save-credentials] ```javascript await startiapp.Biometrics.setUsernameAndPassword("user@example.com", "s3cret"); ``` Retrieve credentials [#retrieve-credentials] When retrieving, the user is prompted for biometric verification: ```javascript const credentials = await startiapp.Biometrics.getUsernameAndPassword( "Authenticate", // title shown in the biometric prompt "Log in to your account", // reason text ); if (credentials) { console.log(credentials.username, credentials.password); // Use credentials to log in } else { console.log("Biometric authentication failed or no saved credentials"); } ``` Check if credentials exist [#check-if-credentials-exist] ```javascript const hasCreds = await startiapp.Biometrics.hasUsernameAndPassword(); ``` Remove saved credentials [#remove-saved-credentials] ```javascript await startiapp.Biometrics.removeUsernameAndPassword(); ``` Option B: Form-based auto-capture flow [#option-b-form-based-auto-capture-flow] Automatically capture credentials from a login form and prompt the user to save them. 1. Before login, attach the middleware to your form [#1-before-login-attach-the-middleware-to-your-form] ```javascript await startiapp.Biometrics.startSaveUsernameAndPassword({ usernameInputFieldSelector: "#email", passwordInputFieldSelector: "#password", submitButtonSelector: "#login-button", title: "Authenticate", reason: "Save your login for next time", language: "en", // or "da" }); ``` 2. After successful login, save the captured credentials [#2-after-successful-login-save-the-captured-credentials] ```javascript await startiapp.Biometrics.endSaveUsernameAndPassword(); ``` The SDK intercepts the form submission, captures the entered credentials, asks the user if they want to use biometrics next time, and saves the credentials if they agree. Checking biometric availability [#checking-biometric-availability] ```javascript const type = await startiapp.Biometrics.getAuthenticationType(); // Returns: "face" | "fingerprint" | "none" ``` See also [#see-also] Full API reference for the Biometrics module # Biometric Login with OAuth Biometric Login with OAuth [#biometric-login-with-oauth] After a user signs in with an OAuth provider, you can store a session token behind Face ID or Touch ID so returning users skip the full login flow. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * An OAuth provider is configured for your app in the starti.app manager * The device has biometric hardware (Face ID or fingerprint) This pattern works with any OAuth provider. The examples below use MitID, but you can substitute Google, Apple, or any other supported provider. Steps [#steps] Check biometric availability [#check-biometric-availability] Before offering biometric login, check that the device supports it: ```javascript const biometricType = await startiapp.Biometrics.getAuthenticationType(); const hasBiometrics = biometricType !== "none"; ``` If `biometricType` is `"none"`, skip the biometric flow and always use the full OAuth login. First login: authenticate and store the session [#first-login-authenticate-and-store-the-session] After a successful OAuth sign-in, send the authorization code to your backend as usual. When your backend returns a session token, store it with `setSecuredContent`: ```javascript // 1. Sign in with the OAuth provider const result = await startiapp.Auth.signIn("signaturgruppenmitid"); if (!result.isSuccess) { console.error("Sign in failed:", result.errorMessage); return; } // 2. Exchange the code on your backend const response = await fetch("/api/auth/mitid", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, }), }); const session = await response.json(); // 3. Store the session token behind biometrics await startiapp.Biometrics.setSecuredContent(session.token); ``` Saving does not trigger a biometric prompt. Return visit: retrieve the session with biometrics [#return-visit-retrieve-the-session-with-biometrics] On subsequent visits, check if a stored session exists. If it does, retrieve it with a biometric prompt instead of starting a full OAuth login: ```javascript const hasSession = await startiapp.Biometrics.hasSecuredContent(); if (hasSession) { const token = await startiapp.Biometrics.getSecuredContent( "Log in", "Verify your identity" ); if (token) { // Use the stored token with your backend const response = await fetch("/api/auth/session", { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { console.log("Resumed session"); } else { // Token expired or invalid — clear and do full login await startiapp.Biometrics.removeSecuredContent(); await doFullLogin(); } } else { // User cancelled biometrics or scan failed — do full login await doFullLogin(); } } else { await doFullLogin(); } ``` Sign out: clear stored data [#sign-out-clear-stored-data] When the user signs out, remove the stored session token alongside your normal sign-out logic: ```javascript await startiapp.Biometrics.removeSecuredContent(); await startiapp.Auth.signOut(); ``` Handle expired tokens gracefully. If `getSecuredContent` returns a token but your backend rejects it, call `removeSecuredContent()` and fall back to a full OAuth login. Note that `getSecuredContent` returns `null` both when nothing is stored and when the user cancels the biometric prompt — always treat `null` as "no stored session". Complete example [#complete-example] ```javascript await startiapp.initialize(); const biometricType = await startiapp.Biometrics.getAuthenticationType(); const hasBiometrics = biometricType !== "none"; async function login() { // Try biometric resume first if (hasBiometrics) { const hasSession = await startiapp.Biometrics.hasSecuredContent(); if (hasSession) { const token = await startiapp.Biometrics.getSecuredContent( "Log in", "Verify your identity" ); if (token) { const response = await fetch("/api/auth/session", { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const user = await response.json(); console.log("Welcome back,", user.name); return; } // Token expired — clear and continue to full login await startiapp.Biometrics.removeSecuredContent(); } } } // Full OAuth login const result = await startiapp.Auth.signIn("signaturgruppenmitid"); if (!result.isSuccess) { alert("Login failed: " + result.errorMessage); return; } const response = await fetch("/api/auth/mitid", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, }), }); const session = await response.json(); console.log("Logged in:", session.name); // Store for biometric resume next time if (hasBiometrics) { await startiapp.Biometrics.setSecuredContent(session.token); } } async function logout() { await startiapp.Biometrics.removeSecuredContent(); await startiapp.Auth.signOut(); console.log("Logged out"); } ``` See also [#see-also] Full API reference for the Biometrics module Full API reference for the Auth module Authenticate users with MitID # Capture Images Capture Images [#capture-images] Use standard HTML file inputs to capture images from the camera or pick them from the gallery. The starti.app container handles the native camera UI automatically. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * The device has a camera (for capture) Take a photo with the camera [#take-a-photo-with-the-camera] Use an `` element with the `capture` attribute. This opens the camera directly, skipping the file chooser: ```html ``` ```javascript const input = document.getElementById("camera-input"); input.addEventListener("change", () => { const file = input.files[0]; if (file) { console.log("Photo taken:", file.name, file.size, "bytes"); } }); ``` Choose the camera direction [#choose-the-camera-direction] | `capture` value | Camera | | --------------- | --------------------- | | `"environment"` | Rear camera | | `"user"` | Front camera (selfie) | ```html ``` Pick from the gallery [#pick-from-the-gallery] Remove the `capture` attribute to show a chooser that lets the user pick an existing photo **or** take a new one: ```html ``` Programmatic capture [#programmatic-capture] Create the input element in JavaScript for a button-driven flow: ```javascript document.getElementById("take-photo-btn").addEventListener("click", async () => { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.capture = "environment"; const file = await new Promise((resolve) => { input.addEventListener("change", () => resolve(input.files?.[0] ?? null)); input.click(); }); if (!file) return; // Show preview const img = document.getElementById("preview"); img.src = URL.createObjectURL(file); // Upload const formData = new FormData(); formData.append("photo", file); await fetch("/api/upload", { method: "POST", body: formData }); }); ``` Capture a video [#capture-a-video] ```html ``` Multiple files [#multiple-files] ```html ``` File inputs use standard web APIs and work in regular mobile browsers too — they are not exclusive to the starti.app container. See also [#see-also] Full reference for camera and file capture behavior Use the SDK's built-in QR scanner for barcode scanning # Control App UI Control App UI [#control-app-ui] Customize the native app chrome: status bar, navigation spinner, screen rotation, and swipe navigation. Everything set here through `initialize()` can also be configured for your brand under [Initialization settings](https://docs.starti.app/en/academy/manager/development-setup/initialization-settings) in the [starti.app Manager](https://manager.starti.app/initialization-settings), and that is the recommended way to set it: it applies to your whole app with no code, and is changed by publishing rather than releasing your website again. Pass options to `initialize()` only for values that can only be decided at runtime. The runtime methods below — `setStatusBar()`, `showSpinner()`, `enableScreenRotation()` and the rest — have no Manager equivalent, and are how you change a setting for one screen rather than for the whole app. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized Status bar [#status-bar] Configure on initialization [#configure-on-initialization] Set status bar options when initializing the SDK: ```javascript await startiapp.initialize({ statusBar: { removeSafeArea: false, safeAreaBackgroundColor: "#ffffff", hideText: false, darkContent: true, }, }); ``` Change at runtime [#change-at-runtime] Options are merged onto the current configuration, so you can change a single property without resetting the others: ```javascript startiapp.App.setStatusBar({ removeSafeArea: false, safeAreaBackgroundColor: "#1a1a2e", hideText: false, darkContent: false, // light text for dark backgrounds }); // Update only the content colour startiapp.App.setStatusBar({ darkContent: true }); // dark icons for light backgrounds ``` `darkContent` defaults to `"auto"`, which lets the app choose the content colour from the configured safe area background colour's brightness. It does not inspect the actual pixels or CSS background behind the status bar. If you set `removeSafeArea: true`, your web content extends behind the status bar. In that case, set `darkContent` explicitly to `true` or `false` so the status bar stays readable over your page content. Use `true` for dark icons on light backgrounds, and `false` for light icons on dark backgrounds. ```javascript startiapp.App.setStatusBar({ darkContent: "auto" }); ``` Hide / show [#hide--show] ```javascript await startiapp.App.hideStatusBar(); await startiapp.App.showStatusBar(); ``` Set safe area background color [#set-safe-area-background-color] ```javascript await startiapp.App.setSafeAreaBackgroundColor("#ff0000"); ``` Navigation spinner [#navigation-spinner] The spinner shows during page navigation. Configure it globally: ```javascript await startiapp.initialize({ spinner: { show: true, color: "#3498db", afterMilliseconds: 300, excludedDomains: ["api.example.com"], }, }); ``` Show / hide programmatically [#show--hide-programmatically] ```javascript await startiapp.App.showSpinner(); await startiapp.App.hideSpinner(); ``` Screen rotation [#screen-rotation] ```javascript // Allow the screen to rotate await startiapp.App.enableScreenRotation(); // Lock to current orientation await startiapp.App.disableScreenRotation(); ``` Swipe navigation [#swipe-navigation] Control iOS-style swipe-back and swipe-forward gestures: ```javascript // Enable swipe gestures await startiapp.App.enableSwipeNavigation(); // Disable swipe gestures (useful for maps, carousels, etc.) await startiapp.App.disableSwipeNavigation(); ``` The app's gesture navigates the browser history, which on a site that routes with `history.pushState` is a full page load. To turn it off and handle the swipe in your own page instead, see [Gestures](/sdk/reference/gestures). Set all options at initialization [#set-all-options-at-initialization] Combine everything in a single `initialize` call: ```javascript await startiapp.initialize({ allowZoom: false, allowRotation: false, allowDrag: true, allowScrollBounce: false, allowSwipeNavigation: true, statusBar: { removeSafeArea: false, safeAreaBackgroundColor: "#ffffff", hideText: false, darkContent: true, }, spinner: { show: true, color: "#000000", afterMilliseconds: 200, excludedDomains: [], }, }); ``` Splash animation [#splash-animation] A brand can ship more than one splash animation and name each one in the starti.app Manager. Your page picks which one the app opens with: ```javascript // After sign-in: this device opens on the "returning" animation from now on startiapp.App.setSplashVariant("returning"); // After sign-out: back to the default animation startiapp.App.setSplashVariant(null); ``` The choice takes effect **from the next launch**, not on the splash currently showing — the app builds its splash before your page exists. It is stored on the device and survives launches and app updates, so treat it as "what this installation opens with", not as part of your session state. Names that the app does not know — an older app version, or a variant the brand has since removed — fall back to the default animation, so it is safe to call while a new animation is still rolling out. Apps older than 4.229.0 ignore the call entirely; ask `startiapp.App.supports("splashVariants")` if your page needs to know. Options stack [#options-stack] Push and pop option sets for different views: ```javascript // Enter a fullscreen video view startiapp.App.pushOptions({ allowRotation: true, allowSwipeNavigation: false, }); // Leave the fullscreen view, restore previous settings startiapp.App.popOptions(); ``` See also [#see-also] Full API reference for the App module # Debug Your App Enable USB debugging [#enable-usb-debugging] Open Developer Options [#open-developer-options] Go to **Settings > About** (or similar) and tap the **Build number** seven times to unlock **Settings > Developer Options**. Enable USB debugging [#enable-usb-debugging-1] Go to **Developer Options** and enable the **USB debugging** option. See the [Android documentation](https://developer.android.com/studio/debug/) for a detailed walk-through. Connect Chrome Developer Tools [#connect-chrome-developer-tools] Open the inspector [#open-the-inspector] In your PC's Chrome browser, navigate to `chrome://inspect`. Connect your device [#connect-your-device] Connect your phone to your PC via USB and open the test version of your app. You may need to allow the connection on your phone — select **always allow**. Inspect the WebView [#inspect-the-webview] Your phone should appear under **Remote Target** in Chrome. Find the WebView entry and click **Inspect** to open Developer Tools. For more details, see the [Chrome remote debugging documentation](https://developer.chrome.com/docs/devtools/remote-debugging/webviews/). Enable Developer Mode in Safari [#enable-developer-mode-in-safari] Open Safari preferences [#open-safari-preferences] Open Safari on your Mac and go to **Safari > Preferences** in the menu bar. Enable developer features [#enable-developer-features] Click the **Advanced** tab and check **Show features for web developers** at the bottom. This adds the **Develop** menu to Safari. Connect Safari Web Inspector [#connect-safari-web-inspector] Enable Web Inspector on your device [#enable-web-inspector-on-your-device] On your iOS device, go to **Settings > Safari > Advanced** and toggle on **Web Inspector**. Connect your device [#connect-your-device-1] Connect your iOS device to your Mac using a USB cable. Inspect the WebView [#inspect-the-webview-1] Open Safari on your Mac, go to the **Develop** menu, then select your iOS device and the WebView you want to inspect. If your latest deploy isn't showing up in the app at all, the cause is usually caching rather than your code — see [Caching](/sdk/explanation/caching). # Handle Apple Server Notifications Handle Apple Server Notifications [#handle-apple-server-notifications] When a user subscribes, renews, cancels, or gets refunded, Apple sends a signed HTTP POST to your server in real time. These are called [App Store Server Notifications V2](https://developer.apple.com/documentation/appstoreservernotifications). Without server notifications, your backend only knows about subscription changes when the app explicitly checks — which doesn't happen if the user isn't actively using the app. Server notifications let you react immediately: revoke access on refund, send win-back emails on cancellation, or extend grace periods on billing failures. Notification types [#notification-types] Each notification has a `notificationType` and an optional `subtype`. Here are the most common ones and what to do with them: | Type | Subtype | What happened | Recommended action | | --------------------------- | ------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------- | | `DID_RENEW` | — / `BILLING_RECOVERY` | Subscription renewed successfully | Extend the user's access period | | `DID_CHANGE_RENEWAL_STATUS` | `AUTO_RENEW_DISABLED` | User turned off auto-renew | Flag for win-back; access continues until expiry | | `DID_CHANGE_RENEWAL_STATUS` | `AUTO_RENEW_ENABLED` | User re-enabled auto-renew | Clear any cancellation flags | | `DID_FAIL_TO_RENEW` | `GRACE_PERIOD` | Payment failed, but Apple is retrying | Keep access active, notify user to update payment method | | `DID_FAIL_TO_RENEW` | — | Payment failed, no grace period | Revoke or restrict access | | `EXPIRED` | `VOLUNTARY` / `BILLING_RETRY` / `PRICE_INCREASE` | Subscription ended | Revoke access | | `REFUND` | — | Apple issued a refund | Revoke access immediately | | `SUBSCRIBED` | `INITIAL_BUY` | New subscription | Grant access (acts as server-side backup for the client flow) | | `SUBSCRIBED` | `RESUBSCRIBE` | User re-subscribed after expiry | Restore access | This table covers the most common types. See Apple's [notificationType reference](https://developer.apple.com/documentation/appstoreservernotifications/notificationtype) for the full list, including `OFFER_REDEEMED`, `REVOKE`, `CONSUMPTION_REQUEST`, and others. Verify the signed payload [#verify-the-signed-payload] Apple sends each notification as a JSON body with a single `signedPayload` field. This is a JWS (JSON Web Signature) signed by Apple — you must verify it before trusting the contents. Install Apple's official library: ```bash npm install @apple/app-store-server-library ``` Download Apple's root certificates (you need all three): * [AppleRootCA-G2](https://www.apple.com/certificateauthority/AppleRootCA-G2.cer) * [AppleRootCA-G3](https://www.apple.com/certificateauthority/AppleRootCA-G3.cer) * [AppleComputerRootCertificate](https://www.apple.com/certificateauthority/AppleComputerRootCertificate.cer) Set up the verifier: ```javascript import { SignedDataVerifier, Environment } from "@apple/app-store-server-library"; import fs from "fs"; const appleRootCAs = [ fs.readFileSync("./certs/AppleRootCA-G2.cer"), fs.readFileSync("./certs/AppleRootCA-G3.cer"), fs.readFileSync("./certs/AppleComputerRootCertificate.cer"), ]; const verifier = new SignedDataVerifier( appleRootCAs, true, // enable online OCSP checks Environment.PRODUCTION, // or Environment.SANDBOX for testing "com.example.myapp", // your app's bundle ID 123456789 // your app's Apple ID (required for production) ); ``` Verify and decode a notification: ```javascript const decoded = await verifier.verifyAndDecodeNotification(signedPayload); console.log(decoded.notificationType); // e.g. "DID_RENEW" console.log(decoded.subtype); // e.g. null or "GRACE_PERIOD" // Extract the transaction details (also a signed JWS, decoded automatically) if (decoded.data?.signedTransactionInfo) { const transaction = await verifier.verifyAndDecodeTransaction( decoded.data.signedTransactionInfo ); console.log(transaction.transactionId); console.log(transaction.productId); console.log(transaction.expiresDate); } ``` Never skip verification. Without it, anyone can POST fake notifications to your endpoint and grant themselves free subscriptions. Set up in App Store Connect [#set-up-in-app-store-connect] 1. Open [App Store Connect](https://appstoreconnect.apple.com) and navigate to your app 2. Go to **App Information** (under General) 3. Scroll to **App Store Server Notifications** 4. Enter your production endpoint URL (e.g. `https://api.example.com/webhooks/apple`) 5. Select **Version 2** — do not use V1, it's a legacy format 6. Optionally, enter a separate sandbox URL for testing 7. Click **Save** To verify your endpoint is working, use the **Request a Test Notification** button or call the [Request a Test Notification](https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification) API endpoint. Node.js reference implementation [#nodejs-reference-implementation] A complete Express handler that receives, verifies, and routes Apple server notifications: ```javascript import express from "express"; import { SignedDataVerifier, Environment } from "@apple/app-store-server-library"; import fs from "fs"; const app = express(); app.use(express.json()); const verifier = new SignedDataVerifier( [ fs.readFileSync("./certs/AppleRootCA-G2.cer"), fs.readFileSync("./certs/AppleRootCA-G3.cer"), fs.readFileSync("./certs/AppleComputerRootCertificate.cer"), ], true, Environment.PRODUCTION, process.env.APPLE_BUNDLE_ID, process.env.APPLE_APP_ID ? Number(process.env.APPLE_APP_ID) : undefined ); app.post("/webhooks/apple", async (req, res) => { const { signedPayload } = req.body; if (!signedPayload) { return res.sendStatus(400); } // Always respond quickly — Apple retries on timeout res.sendStatus(200); try { const notification = await verifier.verifyAndDecodeNotification(signedPayload); const { notificationType, subtype } = notification; // Decode the transaction if present let transaction; if (notification.data?.signedTransactionInfo) { transaction = await verifier.verifyAndDecodeTransaction( notification.data.signedTransactionInfo ); } switch (notificationType) { case "SUBSCRIBED": // New subscription or resubscribe — grant access await grantAccess(transaction); break; case "DID_RENEW": // Renewal — extend access period await extendAccess(transaction); break; case "DID_CHANGE_RENEWAL_STATUS": if (subtype === "AUTO_RENEW_DISABLED") { await flagForWinBack(transaction); } // AUTO_RENEW_ENABLED — clear cancellation flags break; case "DID_FAIL_TO_RENEW": if (subtype === "GRACE_PERIOD") { await notifyPaymentIssue(transaction); } else { await restrictAccess(transaction); } break; case "EXPIRED": await revokeAccess(transaction); break; case "REFUND": await revokeAccess(transaction); break; default: console.log(`Unhandled notification: ${notificationType}/${subtype}`); } } catch (error) { console.error("Failed to verify Apple notification:", error); } }); ``` **Respond 200 immediately.** Apple retries failed deliveries up to 5 times at increasing intervals (1h, 12h, 24h, 48h, 72h). If your endpoint returns a non-2xx status, you'll receive duplicate notifications. Process the notification asynchronously after responding. **Idempotency.** Apple may send the same notification more than once. Use `notification.notificationUUID` to deduplicate — store processed UUIDs and skip any you've already handled. **Sandbox testing.** Use `Environment.SANDBOX` and your sandbox notification URL during development. Sandbox subscriptions renew on an accelerated schedule — durations vary by subscription length and test environment. See Apple's [testing documentation](https://developer.apple.com/documentation/storekit/testing-an-auto-renewable-subscription) for current rates. See also [#see-also] Set up Google Play Real-Time Developer Notifications via Pub/Sub Client-side subscription guide — purchasing, restoring, and checking status Full API reference for the InAppPurchase module # Handle Domains Handle Domains [#handle-domains] Control how links behave in your app: keep them in the main webview, open them in an in-app browser, or send them to the device's browser. How domain handling works [#how-domain-handling-works] When a user taps a link, the app decides where to open it based on the domain: * **Internal** domains load in the main webview — the user stays on your page. * **External** domains — URLs matching a pattern you add with `addExternalDomains()` — open in the device's browser (Safari, Chrome), outside the app. * **Every other domain** opens in an in-app browser — a browser view that appears on top of your app. The user can close it to return to your page. By default, only the domain your app starts on is treated as internal, and there are no external patterns, so all other domains open in the in-app browser. Add an internal domain [#add-an-internal-domain] Internal domains load inside the app's webview: ```javascript await startiapp.App.addInternalDomain("partner-site.com"); ``` Add external domains [#add-external-domains] External domains are specified as `RegExp` patterns. A matching URL opens in the device's browser, even when it also matches an internal domain: ```javascript await startiapp.App.addExternalDomains(/maps\.google\.com/, /youtube\.com/); ``` Remove domains [#remove-domains] ```javascript // Remove a specific internal domain await startiapp.App.removeInternalDomain("partner-site.com"); // Remove external domain patterns await startiapp.App.removeExternalDomains(/maps\.google\.com/); ``` List configured domains [#list-configured-domains] ```javascript const internal = await startiapp.App.getInternalDomains(); const external = await startiapp.App.getExternalDomains(); ``` Handle all domains internally [#handle-all-domains-internally] This is especially useful for flows you don't fully control — for example, payment flows where the user is redirected through several third-party domains before returning to your site. Rather than adding each domain individually, you can treat everything as internal and only exclude specific domains that should open externally. ```javascript // Treat all domains as internal await startiapp.App.handleAllDomainsInternally(); // But send specific ones to the device's browser await startiapp.App.addExternalDomains(/maps\.google\.com/); ``` When you no longer need all domains to be internal, call `restoreDefaultDomainHandling()` to go back to the default behavior: ```javascript await startiapp.App.restoreDefaultDomainHandling(); ``` `handleAllDomainsInternally()` is a session-only setting — it resets automatically when the app restarts. You only need to call `restoreDefaultDomainHandling()` if you want to revert during the same session. Open a URL in the device's browser [#open-a-url-in-the-devices-browser] To open one specific URL in the device's browser (Safari, Chrome) — completely outside the app — call `openExternalBrowser()`. External domains do the same for every link that matches a pattern. ```javascript await startiapp.App.openExternalBrowser("https://maps.google.com/?q=Copenhagen"); ``` See also [#see-also] Learn how domain routing works under the hood Full API reference for the App module # Handle Google Server Notifications Handle Google Server Notifications [#handle-google-server-notifications] When a user subscribes, renews, cancels, or gets refunded on Google Play, Google publishes a notification to a [Cloud Pub/Sub](https://cloud.google.com/pubsub) topic you configure. These are called [Real-Time Developer Notifications (RTDN)](https://developer.android.com/google/play/billing/getting-ready#configure-rtdn). Unlike Apple (which sends HTTP POST requests directly to your server), Google uses Pub/Sub as an intermediary. Your server either receives push deliveries from Pub/Sub or pulls messages from it. Without RTDN, your backend only knows about subscription changes when the app explicitly checks. RTDN lets you react immediately to renewals, cancellations, billing issues, and refunds. Notification types [#notification-types] Each notification contains an integer `notificationType`. Here are the most common ones and what to do with them: | Type | Name | What happened | Recommended action | | ---- | ----------------- | -------------------------------------------------------- | ------------------------------------------------- | | 1 | `RECOVERED` | Payment recovered after billing retry | Restore access | | 2 | `RENEWED` | Subscription renewed | Extend access period | | 3 | `CANCELED` | Subscription cancelled (voluntary or involuntary) | Access continues until expiry; flag for win-back | | 4 | `PURCHASED` | New subscription | Grant access (server-side backup for client flow) | | 5 | `ON_HOLD` | Account hold — payment failed, access paused | Suspend access, notify user to update payment | | 6 | `IN_GRACE_PERIOD` | Grace period started — payment failed, access continues | Keep access, notify user to update payment | | 7 | `RESTARTED` | User restarted subscription from account hold | Restore access | | 9 | `DEFERRED` | Renewal deferred (e.g., pending price change acceptance) | No immediate action | | 10 | `PAUSED` | User paused their subscription | Suspend access at period end | | 12 | `REVOKED` | Subscription revoked (refund or policy violation) | Revoke access immediately | | 13 | `EXPIRED` | Subscription expired | Revoke access | This table covers the most common types. See Google's [SubscriptionNotification reference](https://developer.android.com/google/play/billing/rtdn-reference#sub) for the full list. Decode and verify [#decode-and-verify] Message structure [#message-structure] Pub/Sub delivers a JSON message. If you're using **push delivery**, the HTTP POST body looks like this: ```json { "message": { "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZSIs...", "messageId": "123456789", "publishTime": "2026-04-15T10:00:00Z" }, "subscription": "projects/my-project/subscriptions/play-notifications" } ``` The `message.data` field is a base64-encoded JSON string. Decode it to get the `DeveloperNotification`: ```json { "version": "1.0", "packageName": "com.example.myapp", "eventTimeMillis": "1744707600000", "subscriptionNotification": { "version": "1.0", "notificationType": 2, "purchaseToken": "abc123..." } } ``` Get full subscription state [#get-full-subscription-state] The notification only tells you *what* happened — not the full subscription state. Always call the Google Play Developer API to get the complete picture: ```javascript import { google } from "googleapis"; const auth = new google.auth.GoogleAuth({ keyFile: "./service-account.json", scopes: ["https://www.googleapis.com/auth/androidpublisher"], }); const androidPublisher = google.androidpublisher({ version: "v3", auth }); const subscription = await androidPublisher.purchases.subscriptionsv2.get({ packageName: "com.example.myapp", token: purchaseToken, }); console.log(subscription.data); // { // lineItems: [{ productId, expiryTime, ... }], // subscriptionState: "SUBSCRIPTION_STATE_ACTIVE", // ... // } ``` Verify push requests [#verify-push-requests] If you use push delivery, verify that requests actually come from Pub/Sub. Configure your Pub/Sub subscription with an [OIDC authentication token](https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions), then verify the bearer token in incoming requests: ```javascript import { OAuth2Client } from "google-auth-library"; const authClient = new OAuth2Client(); async function verifyPubSubToken(req) { const bearer = req.headers.authorization?.split(" ")[1]; if (!bearer) throw new Error("Missing authorization header"); const ticket = await authClient.verifyIdToken({ idToken: bearer, audience: process.env.PUBSUB_AUDIENCE, // your endpoint URL }); const claim = ticket.getPayload(); // Optionally verify claim.email matches your Pub/Sub service account return claim; } ``` Without push authentication, anyone who discovers your endpoint URL can send fake notifications. Always verify the OIDC token in production. Set up Pub/Sub and Google Play Console [#set-up-pubsub-and-google-play-console] 1. Create a Pub/Sub topic [#1-create-a-pubsub-topic] In the [Google Cloud Console](https://console.cloud.google.com/cloudpubsub): 1. Go to **Pub/Sub → Topics** 2. Click **Create topic** 3. Name it (e.g., `play-subscription-notifications`) 4. Click **Create** 2. Grant Google Play publish permission [#2-grant-google-play-publish-permission] On the topic you just created: 1. Click the topic name → **Permissions** tab 2. Click **Add principal** 3. Enter: `google-play-developer-notifications@system.gserviceaccount.com` 4. Assign the **Pub/Sub Publisher** role 5. Click **Save** 3. Create a subscription [#3-create-a-subscription] Still in Google Cloud Console, create a subscription on the topic: * **Push** (recommended for most setups): Enter your endpoint URL (e.g., `https://api.example.com/webhooks/google`). Enable authentication with an OIDC token. * **Pull**: No endpoint needed — your server polls for messages. Better if your server is behind a firewall. | | Push | Pull | | ------------ | --------------------------------------- | ---------------------------- | | **Setup** | Provide an HTTPS endpoint | No endpoint needed | | **Latency** | Near-instant delivery | Depends on polling frequency | | **Firewall** | Endpoint must be publicly reachable | Works behind firewalls | | **Retries** | Pub/Sub retries unacknowledged messages | You control retry logic | 4. Connect to Google Play Console [#4-connect-to-google-play-console] 1. Open [Google Play Console](https://play.google.com/console) 2. Go to **Monetize → Monetization setup** 3. In the **Real-time developer notifications** section, enter the full topic name: `projects/YOUR_PROJECT_ID/topics/play-subscription-notifications` 4. Click **Send test notification** to verify the pipeline 5. Click **Save** Node.js reference implementation [#nodejs-reference-implementation] A complete Express handler for Pub/Sub push delivery: ```javascript import express from "express"; import { google } from "googleapis"; import { OAuth2Client } from "google-auth-library"; const app = express(); app.use(express.json()); // Google Play Developer API client const auth = new google.auth.GoogleAuth({ keyFile: "./service-account.json", scopes: ["https://www.googleapis.com/auth/androidpublisher"], }); const androidPublisher = google.androidpublisher({ version: "v3", auth }); // OIDC token verifier for Pub/Sub push auth const authClient = new OAuth2Client(); app.post("/webhooks/google", async (req, res) => { // Verify the Pub/Sub push token try { const bearer = req.headers.authorization?.split(" ")[1]; await authClient.verifyIdToken({ idToken: bearer, audience: process.env.PUBSUB_AUDIENCE, // your endpoint URL }); } catch { // Return 200 to prevent Pub/Sub from retrying invalid tokens endlessly return res.sendStatus(200); } // Acknowledge immediately — Pub/Sub retries unacknowledged messages res.sendStatus(200); try { // Decode the Pub/Sub message const data = JSON.parse( Buffer.from(req.body.message.data, "base64").toString() ); const notification = data.subscriptionNotification; if (!notification) { // Not a subscription notification (could be a test or voided purchase) return; } const { notificationType, purchaseToken } = notification; // Get the full subscription state from the API const { data: subscription } = await androidPublisher.purchases.subscriptionsv2.get({ packageName: data.packageName, token: purchaseToken, }); switch (notificationType) { case 4: // PURCHASED await grantAccess(subscription); break; case 1: // RECOVERED case 2: // RENEWED case 7: // RESTARTED await extendAccess(subscription); break; case 3: // CANCELED await flagForWinBack(subscription); break; case 5: // ON_HOLD case 10: // PAUSED await suspendAccess(subscription); break; case 6: // IN_GRACE_PERIOD await notifyPaymentIssue(subscription); break; case 12: // REVOKED case 13: // EXPIRED await revokeAccess(subscription); break; default: console.log(`Unhandled notification type: ${notificationType}`); } } catch (error) { console.error("Failed to process Google notification:", error); } }); ``` **Acknowledge immediately.** Return 200 before processing. Pub/Sub retries unacknowledged messages, which leads to duplicate processing if your handler is slow. **Idempotency.** Pub/Sub guarantees at-least-once delivery — you may receive the same message more than once. Deduplicate using `message.messageId` from the Pub/Sub envelope, or a combination of `purchaseToken` + `notificationType`. **Always call the API.** The notification only tells you the event type. The `purchases.subscriptionsv2.get` response contains the actual subscription state, expiry time, and line items. Make access decisions based on the API response, not the notification type alone. See also [#see-also] Set up Apple App Store Server Notifications V2 Client-side subscription guide — purchasing, restoring, and checking status Full API reference for the InAppPurchase module # Manage Push Topics Manage Push Notification Topics [#manage-push-notification-topics] Topics let you segment your push notifications so users only receive what they are interested in. For example, you might create topics like "News", "Offers", and "Order updates" — and let users choose which ones they want to subscribe to. Topics are created in the [starti.app Manager](https://manager.starti.app) or automatically when you subscribe to a topic that doesn't exist yet. Once users are subscribed, you can send notifications to a specific topic either from the Manager or programmatically through the [REST API](/sdk/rest-api/push-notifications). For the full picture on how push notifications work — including alternative approaches that don't require topics — see [Push Notifications](/sdk/getting-started/push-notifications). Request push notification access [#request-push-notification-access] Before subscribing to topics, request permission: ```javascript const granted = await startiapp.PushNotification.requestAccess(); if (!granted) { console.log("User denied push notification permissions"); } ``` Get available topics [#get-available-topics] Fetch the list of topics configured for your app: ```javascript const topics = await startiapp.PushNotification.getTopics(); topics.forEach((topic) => { console.log(topic.name, topic.subscribed); }); ``` Subscribe to topics [#subscribe-to-topics] ```javascript const subscribed = await startiapp.PushNotification.subscribeToTopics([ "news", "promotions", ]); console.log("Subscribed to:", subscribed); // [{ topic: "news", name: "News" }, { topic: "promotions", name: "Promotions" }] ``` Unsubscribe from topics [#unsubscribe-from-topics] ```javascript await startiapp.PushNotification.unsubscribeFromTopics(["promotions"]); ``` Build a topic preferences UI [#build-a-topic-preferences-ui] ```javascript await startiapp.initialize(); const topics = await startiapp.PushNotification.getTopics(); topics.forEach((topic) => { const toggle = document.createElement("input"); toggle.type = "checkbox"; toggle.checked = topic.subscribed; toggle.addEventListener("change", async () => { if (toggle.checked) { await startiapp.PushNotification.subscribeToTopics([topic.topic]); } else { await startiapp.PushNotification.unsubscribeFromTopics([topic.topic]); } }); const label = document.createElement("label"); label.textContent = topic.name; label.prepend(toggle); document.body.appendChild(label); }); ``` Send a notification to a topic [#send-a-notification-to-a-topic] Once users have subscribed, you can send notifications to a topic from the Manager — or from your own backend using the [REST API](/sdk/rest-api/push-notifications). Here is an example using `curl`: ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "topics": ["news"], "title": "Weekly update", "body": "Check out what happened this week" } ]' \ https://api.starti.app/v1/push-notifications/send ``` The notification is delivered to all devices subscribed to the `news` topic. See the [REST API reference](/sdk/rest-api/push-notifications) for the full list of options, including `openToUrl` and `badgeCount`. See also [#see-also] Full API reference for the PushNotification module # Scan QR Codes Scan QR Codes [#scan-qr-codes] Use the SDK's QR scanner to scan QR codes from the device camera, with optional validation logic. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Camera access is available on the device Basic scan [#basic-scan] Open the scanner and get the scanned value: ```javascript const result = await startiapp.QrScanner.scan(); console.log("Scanned:", result); ``` Scan with validation [#scan-with-validation] Pass a `validation` function that accepts or rejects scanned values. The scanner stays open until a valid code is scanned or the user closes it: ```javascript const result = await startiapp.QrScanner.scan({ validation: (scannedValue) => { return scannedValue.startsWith("TICKET-"); }, }); if (result) { console.log("Valid ticket:", result); } ``` Async validation [#async-validation] The validation function can be async, for example to verify a code against your server: ```javascript const result = await startiapp.QrScanner.scan({ validation: async (scannedValue) => { const res = await fetch(`/api/verify?code=${scannedValue}`); return res.ok; }, }); ``` Scanner options [#scanner-options] | Option | Type | Default | Description | | ------------------------ | ------------------------------------------------ | -------- | ----------------------------- | | `showCameraPreview` | `boolean` | `true` | Show the camera feed | | `cameraFacing` | `"front" \| "back"` | `"back"` | Which camera to use | | `showCameraSwitchButton` | `boolean` | `true` | Show the camera switch button | | `validation` | `(value: string) => boolean \| Promise` | — | Validation function | ```javascript await startiapp.QrScanner.scan({ cameraFacing: "front", showCameraSwitchButton: false, }); ``` Stop the scanner programmatically [#stop-the-scanner-programmatically] ```javascript await startiapp.QrScanner.stop(); ``` Listening for scan events [#listening-for-scan-events] If you need to scan multiple codes in a row — for example, scanning a batch of tickets at an entrance — use the `qrCodeScanned` event instead of awaiting the result. The scanner stays open after each scan, so the user can continue scanning without leaving the scanner view. ```javascript startiapp.QrScanner.addEventListener("qrCodeScanned", async (event) => { const scannedValue = event.detail; console.log("Scanned:", scannedValue); // Give the user feedback — vibrate or play a sound await startiapp.App.vibrate(1); // medium intensity }); startiapp.QrScanner.addEventListener("qrScannerClosed", () => { console.log("Scanner was closed"); }); // Open the scanner — it stays open until the user closes it or you call stop() startiapp.QrScanner.scan(); ``` This pairs well with a [vibration](/sdk/reference/app#vibrateintensity-vibrationintensity-promisevoid) or a short audio cue to confirm each successful scan. Checking camera access [#checking-camera-access] ```javascript const granted = await startiapp.QrScanner.isCameraAccessGranted(); if (!granted) { await startiapp.QrScanner.requestCameraAccess(); } ``` See also [#see-also] Full API reference for the QrScanner module # Setup Google Analytics Setup Google Analytics [#setup-google-analytics] If your website already uses Google Analytics, starti.app automatically passes analytics data from the app to GA4 — no extra code needed on your part. To distinguish app traffic from website traffic in your reports, you need to create a custom dimension in Google Analytics. Open Google Analytics [#open-google-analytics] Go to your Google Analytics property and click **Admin** in the bottom-left corner. Click Admin in Google Analytics Go to Custom definitions [#go-to-custom-definitions] In the property settings column, click **Custom definitions**. Select Custom definitions Create a custom dimension [#create-a-custom-dimension] Click **Create custom dimension**. Click Create custom dimension Configure the dimension [#configure-the-dimension] Fill in the fields as shown below and click **Save**. | Field | Value | | -------------- | ----------- | | Dimension name | `startiapp` | | Scope | **User** | | User property | `startiapp` | New custom dimension form Verify the data [#verify-the-data] Open your app and use it for a few minutes. Then check your Google Analytics reports — you should see `startiapp` as a user property that lets you filter and segment app users. It may take a few hours for Google to start displaying the data from the app. Use the dimension in reports [#use-the-dimension-in-reports] The custom dimension only becomes useful once you can slice your reports by it. In GA4 that is done with **comparisons**. Isolate app traffic [#isolate-app-traffic] Open a report [#open-a-report] Click **Reports** in the left-hand menu. Add a comparison [#add-a-comparison] Click **Add comparison**, then **Create new**. Match on the dimension [#match-on-the-dimension] | Field | Value | | ---------- | ------------------- | | Dimension | `startiapp` | | Match Type | **exactly matches** | | Value | `true` | The **Summary** now reads `startiapp exactly matches 'true'`. Save it [#save-it] Click **Save** and give the comparison a name and description — for example **starti.app** and *Traffic came through the app*. Compare against mobile web traffic [#compare-against-mobile-web-traffic] The interesting comparison is usually app users against users who are on a mobile device but *not* in the app. Create a second comparison the same way, with two conditions: | Condition | Dimension | Match Type | Value | | --------- | ----------------- | ------------------- | -------- | | Exclude | `startiapp` | **exactly matches** | `true` | | Include | `Device category` | **exactly matches** | `mobile` | Name it something like **Mobile web** and *Mobile traffic that did not come through the app*. With both comparisons active, every report shows app users and mobile web users side by side. # Set Up In-App Purchases Set Up In-App Purchases [#set-up-in-app-purchases] Use the SDK to offer in-app purchases for consumable and non-consumable products on iOS and Android. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Products are configured in App Store Connect and/or Google Play Console * Product IDs match between the store and your code Get product details [#get-product-details] Fetch a product's name, description, and localized price before displaying it to the user: ```javascript const result = await startiapp.InAppPurchase.getProduct( "com.example.premium", "nonconsumable", ); if (result.success) { console.log("Name:", result.value.name); console.log("Price:", result.value.localizedPrice); console.log("Currency:", result.value.currencyCode); console.log("Description:", result.value.description); } else { console.error("Error:", result.errorMessage); } ``` Purchase a product [#purchase-a-product] ```javascript const result = await startiapp.InAppPurchase.purchaseProduct( "com.example.premium", "nonconsumable", ); if (result.success) { console.log("Transaction ID:", result.value.transactionIdentifier); // Validate the transaction on your backend } else { console.error("Purchase failed:", result.errorMessage); } ``` Purchase types [#purchase-types] | Type | Description | | ----------------- | ---------------------------------------------------------- | | `"nonconsumable"` | Bought once, permanent (e.g., premium upgrade, remove ads) | | `"consumable"` | Can be purchased multiple times (e.g., coins, credits) | Complete example [#complete-example] ```javascript await startiapp.initialize(); const productId = "com.example.premium"; // Show product info const product = await startiapp.InAppPurchase.getProduct(productId, "nonconsumable"); if (product.success) { document.getElementById("price").textContent = product.value.localizedPrice; } // Handle purchase document.getElementById("buy-btn").addEventListener("click", async () => { const result = await startiapp.InAppPurchase.purchaseProduct( productId, "nonconsumable", ); if (result.success) { // Send transaction ID to your backend for validation await fetch("/api/validate-purchase", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transactionId: result.value.transactionIdentifier, productId, }), }); } }); ``` Always validate purchases on your backend. Do not trust client-side purchase results alone. See also [#see-also] Full API reference for the InAppPurchase module Guide for auto-renewable subscriptions with upgrades, offers, and status handling # Set Up Subscriptions Set Up Subscriptions [#set-up-subscriptions] Use the SDK to offer auto-renewable subscriptions on iOS and Android. The SDK handles the platform-specific purchase flow and exposes subscription status and promotional offers. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Subscription products are configured in App Store Connect and/or Google Play Console * Product IDs match between the store(s) and your code * Your app includes a "Restore Purchases" button (required by App Store guidelines) Get subscription product details [#get-subscription-product-details] Fetch subscription details including pricing, billing period, and available offers: ```javascript const result = await startiapp.InAppPurchase.getSubscriptionProducts([ "com.example.monthly", "com.example.yearly" ]); if (result.success) { result.value.forEach(product => { console.log(`${product.name}: ${product.localizedPrice} / ${product.subscriptionPeriod}`); if (product.introductoryOffer) { console.log(` Free trial: ${product.introductoryOffer.period}`); } }); } ``` Subscribe [#subscribe] ```javascript const result = await startiapp.InAppPurchase.subscribe("com.example.monthly"); if (result.success) { console.log("Transaction ID:", result.value.transactionId); // 1. Validate on your backend const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transactionId: result.value.transactionId, originalTransactionId: result.value.originalTransactionId, productId: result.value.productId }) }).then(r => r.ok); // 2. Finish the transaction AFTER backend confirms if (validated) { await startiapp.InAppPurchase.finishTransaction(result.value.transactionId); } } ``` Always call `finishTransaction()` after your backend validates the purchase. If you skip this step, the store will re-deliver the transaction on next app launch and may eventually refund the user automatically. Handle unfinished transactions [#handle-unfinished-transactions] If the app crashes, loses internet, or the user's phone dies after a purchase but before `finishTransaction()` is called, the transaction stays unfinished. The SDK automatically checks for unfinished transactions on startup and emits an `"unfinishedTransaction"` event for each one. Register a listener to handle them: ```javascript startiapp.InAppPurchase.addEventListener("unfinishedTransaction", async (event) => { const transaction = event.detail; const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(transaction) }).then(r => r.ok); if (validated) { await startiapp.InAppPurchase.finishTransaction(transaction.transactionId); } }); ``` This listener can be registered before or after `startiapp.initialize()` — the SDK buffers unfinished transactions and delivers them when the first listener is added. On Android, Google Play gives you **3 days** to acknowledge (finish) a purchase. After that, the purchase is automatically refunded. Check subscription status [#check-subscription-status] For a simple yes/no check, use `isSubscribed()`: ```javascript if (await startiapp.InAppPurchase.isSubscribed()) { unlockPremiumFeatures(); } // Check for a specific product const hasYearly = await startiapp.InAppPurchase.isSubscribed("com.example.yearly"); ``` For detailed status (expiry date, renewal info, etc.), use `getActiveSubscriptions()`: ```javascript const result = await startiapp.InAppPurchase.getActiveSubscriptions(); if (result.success) { const active = result.value.filter(s => s.state === "subscribed"); if (active.length > 0) { // User has an active subscription unlockPremiumFeatures(); } } ``` Upgrade or downgrade [#upgrade-or-downgrade] Pass `upgradePolicy` to switch a user between subscription tiers. `upgradePolicy` is **Android only** — on iOS, StoreKit 2 handles transitions automatically and ignores this parameter. ```javascript const result = await startiapp.InAppPurchase.subscribe("com.example.yearly", { upgradePolicy: "immediate" }); ``` | `upgradePolicy` | Behavior | Platform | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | | `"immediate"` | Charges the user immediately and switches the subscription now. The user receives a prorated credit for the remaining time on their old plan. | Android only | | `"deferred"` | The current subscription continues until its expiration date, then the new subscription begins. | Android only | The SDK auto-detects which subscription to replace. On iOS, StoreKit 2 handles same-group upgrades automatically. On Android, the SDK finds the user's active subscription and upgrades from it. If your app offers **multiple subscription groups** (e.g., both an "ad-free" subscription and a separate "premium" subscription), you must pass `oldProductId` to specify which subscription to replace. Without it, the SDK may pick the wrong one on Android. ```javascript // Multiple subscription groups — specify which one to replace const result = await startiapp.InAppPurchase.subscribe("com.example.premium-yearly", { oldProductId: "com.example.premium-monthly", upgradePolicy: "immediate" }); ``` Restore purchases [#restore-purchases] App Store guidelines require a visible "Restore Purchases" button. This syncs with the store and returns all subscriptions (including expired ones), so you can re-validate on your backend: ```javascript const result = await startiapp.InAppPurchase.restorePurchases(); if (result.success) { const active = result.value.filter(s => s.state === "subscribed"); console.log(`Restored ${active.length} active subscription(s)`); active.forEach(s => { console.log(`${s.productId}: ${s.state}`); }); } ``` Manage subscriptions [#manage-subscriptions] Open the platform's native subscription management screen where users can cancel or change their subscription: ```javascript await startiapp.InAppPurchase.manageSubscriptions(); ``` Handle subscription states [#handle-subscription-states] ```javascript const result = await startiapp.InAppPurchase.getActiveSubscriptions(); if (result.success) { for (const sub of result.value) { if (sub.state === "subscribed") { // Grant access } else { // sub.state === "expired" — revoke access } } } ``` Promotional offers [#promotional-offers] Pass `offerId` to `subscribe()` to apply a promotional offer. On iOS, you also need a `resolveOffer` callback that returns a server-generated signature — Apple requires this for promotional offers. On Android, `offerId` is passed directly as the offer token and `resolveOffer` is ignored. ```javascript const result = await startiapp.InAppPurchase.subscribe("com.example.monthly", { offerId: "spring_discount", resolveOffer: async (productId, offerId) => { return await fetch("/api/sign-offer", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productId, offerId }) }).then(r => r.json()); } }); ``` Your backend's `/api/sign-offer` endpoint should generate the signature using your App Store Connect subscription key. It returns: ```json { "signature": "...", "nonce": "a1b2c3d4-...", "timestamp": "1234567890", "keyId": "ABC123" } ``` See Apple's [Generating a Signature for Promotional Offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers) for how to set up the signing key. On Android, `resolveOffer` is never called — you only need the signing endpoint if your app runs on iOS. For offers without server signing (e.g., introductory offers and free trials), no parameters are needed — the store applies them automatically if the user is eligible. Complete example [#complete-example] ```javascript await startiapp.initialize(); // Handle unfinished transactions from previous sessions startiapp.InAppPurchase.addEventListener("unfinishedTransaction", async (event) => { const transaction = event.detail; const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(transaction) }).then(r => r.ok); if (validated) { await startiapp.InAppPurchase.finishTransaction(transaction.transactionId); } }); // Load products const products = await startiapp.InAppPurchase.getSubscriptionProducts([ "com.example.monthly", "com.example.yearly" ]); if (products.success) { products.value.forEach(product => { const btn = document.createElement("button"); btn.textContent = `${product.name} — ${product.localizedPrice}/${product.subscriptionPeriod}`; btn.addEventListener("click", async () => { const result = await startiapp.InAppPurchase.subscribe(product.productId); if (result.success) { // 1. Validate on your backend const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(result.value) }).then(r => r.ok); // 2. Finish the transaction after backend confirms if (validated) { await startiapp.InAppPurchase.finishTransaction(result.value.transactionId); } } }); document.getElementById("plans").appendChild(btn); }); } // Restore button document.getElementById("restore-btn").addEventListener("click", async () => { const result = await startiapp.InAppPurchase.restorePurchases(); if (result.success && result.value.length > 0) { alert(`Restored ${result.value.length} subscription(s)`); } else { alert("No subscriptions found"); } }); // Manage button document.getElementById("manage-btn").addEventListener("click", () => { startiapp.InAppPurchase.manageSubscriptions(); }); ``` Always validate subscriptions on your backend using the App Store Server API or Google Play Developer API. Do not rely solely on client-side status checks for granting access to premium features. See also [#see-also] Full API reference including all subscription types and options Guide for one-time consumable and non-consumable purchases Process App Store Server Notifications V2 on your backend Process Google Play Real-Time Developer Notifications on your backend # Share Content Share Content [#share-content] Use the native share sheet to share files, text, or download files to the device. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized Share a file [#share-a-file] Share a file by providing its URL and a filename. This opens the native share sheet: ```javascript await startiapp.Share.shareFile("https://example.com/report.pdf", "report.pdf"); ``` Share text [#share-text] Share a text string through the native share sheet: ```javascript await startiapp.Share.shareText("Check out this app: https://example.com"); ``` Download a file [#download-a-file] Download a file directly to the device's download folder without showing the share sheet: ```javascript await startiapp.Share.downloadFile( "https://example.com/invoice.pdf", "invoice-2024.pdf", ); ``` Complete example [#complete-example] ```javascript await startiapp.initialize(); // Share button document.getElementById("share-btn").addEventListener("click", async () => { await startiapp.Share.shareText("Join me on this app!"); }); // Download button document.getElementById("download-btn").addEventListener("click", async () => { await startiapp.Share.downloadFile( "https://api.example.com/export/data.csv", "export.csv", ); }); ``` See also [#see-also] Full API reference for the Share module # Sign In with Apple Sign In with Apple [#sign-in-with-apple] Use the SDK's Auth module to sign in users with Apple. Apple sign-in returns a typed result with an `identity` object containing user-specific fields. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Apple sign-in is configured for your app in the starti.app manager Steps [#steps] Call signIn with the "apple" provider [#call-signin-with-the-apple-provider] ```javascript const result = await startiapp.Auth.signIn("apple"); ``` Handle the Apple-specific result [#handle-the-apple-specific-result] When the provider is `"apple"`, the SDK returns an `AuthenticationResultApple` with an `identity` object: ```javascript if (result.isSuccess) { console.log("User ID:", result.identity.userId); console.log("Email:", result.identity.email); console.log("ID Token:", result.identity.idToken); console.log("Name:", result.identity.name); } else { console.error("Sign in failed:", result.errorMessage); } ``` Apple only provides the user's `name` on the **first sign-in**. On subsequent sign-ins, `identity.name` will be `null`. Store the name on your backend when you first receive it. Complete example [#complete-example] ```javascript await startiapp.initialize(); async function loginWithApple() { const result = await startiapp.Auth.signIn("apple"); if (!result.isSuccess) { alert("Login failed: " + result.errorMessage); return; } // Send the identity to your backend await fetch("/api/auth/apple", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: result.identity.userId, email: result.identity.email, name: result.identity.name, idToken: result.identity.idToken, authorizationCode: result.authorizationCode, }), }); } ``` Apple identity fields [#apple-identity-fields] | Field | Type | Description | | --------- | ---------------- | --------------------------------- | | `userId` | `string` | Apple's stable user identifier | | `email` | `string` | The user's email address | | `name` | `string \| null` | Full name (only on first sign-in) | | `idToken` | `string` | A JWT you can verify server-side | See also [#see-also] Full API reference for the Auth module Authenticate users with Google Sign In # Sign In with Google Sign In with Google [#sign-in-with-google] Use the SDK's Auth module to sign in users with their Google account and receive an authorization code for your backend. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Google sign-in is configured for your app in the starti.app manager Steps [#steps] Call signIn with the "google" provider [#call-signin-with-the-google-provider] ```javascript const result = await startiapp.Auth.signIn("google"); ``` Check the result and handle success or failure [#check-the-result-and-handle-success-or-failure] ```javascript if (result.isSuccess) { // Send the authorization code to your backend to exchange for tokens console.log("Authorization code:", result.authorizationCode); console.log("Code verifier:", result.codeVerifier); console.log("Redirect URI:", result.redirectUri); } else { console.error("Sign in failed:", result.errorMessage); } ``` Exchange the code on your backend [#exchange-the-code-on-your-backend] Send `authorizationCode`, `codeVerifier`, and `redirectUri` to your server, which exchanges them with Google for access and refresh tokens. Complete example [#complete-example] ```javascript await startiapp.initialize(); async function loginWithGoogle() { const result = await startiapp.Auth.signIn("google"); if (!result.isSuccess) { alert("Login failed: " + result.errorMessage); return; } // Exchange with your backend const response = await fetch("/api/auth/google", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, }), }); const session = await response.json(); console.log("Logged in as:", session.email); } ``` You can check if the user is already authenticated before showing a sign-in button: ```javascript const loggedIn = await startiapp.Auth.isAuthenticated(); ``` See also [#see-also] Full API reference for the Auth module Authenticate users with Apple Sign In # Sign In with MitID Sign In with MitID [#sign-in-with-mitid] Use the SDK's Auth module to sign in users with MitID, Denmark's national digital identity. MitID authentication is handled through SignaturGruppen as the identity broker. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * MitID (SignaturGruppen) is configured for your app in the starti.app manager Steps [#steps] Call signIn with the "signaturgruppenmitid" provider [#call-signin-with-the-signaturgruppenmitid-provider] ```javascript const result = await startiapp.Auth.signIn("signaturgruppenmitid"); ``` Check the result and handle success or failure [#check-the-result-and-handle-success-or-failure] ```javascript if (result.isSuccess) { console.log("Authorization code:", result.authorizationCode); console.log("Code verifier:", result.codeVerifier); console.log("Redirect URI:", result.redirectUri); } else { console.error("Sign in failed:", result.errorMessage); } ``` Exchange the code on your backend [#exchange-the-code-on-your-backend] Send `authorizationCode`, `codeVerifier`, and `redirectUri` to your server, which exchanges them with SignaturGruppen for access and ID tokens. Requesting additional claims with scopes [#requesting-additional-claims-with-scopes] You can request additional user data by passing a `scope` option. Scope-requested data is available in `result.additionalClaims`. ```javascript const result = await startiapp.Auth.signIn("signaturgruppenmitid", { scope: "openid mitid ssn", }); if (result.isSuccess) { console.log("Claims:", result.additionalClaims); } ``` Common scopes [#common-scopes] | Scope | Description | Available in `additionalClaims` | | ----------------------------------- | --------------------------- | ------------------------------- | | `openid mitid` | Basic MitID login (default) | `sub` | | `openid mitid ssn` | Include CPR number | `sub`, `dk.cpr` | | `openid mitid ssn ssn.details_name` | Include CPR + full name | `sub`, `dk.cpr`, `name` | During development, MitID uses the pre-production environment. You can create test identities at [pp.mitid.dk/test-tool/frontend/#/create-identity](https://pp.mitid.dk/test-tool/frontend/#/create-identity). Complete example [#complete-example] ```javascript await startiapp.initialize(); async function loginWithMitID() { const result = await startiapp.Auth.signIn("signaturgruppenmitid", { scope: "openid mitid ssn", }); if (!result.isSuccess) { alert("Login failed: " + result.errorMessage); return; } // Exchange with your backend const response = await fetch("/api/auth/mitid", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authorizationCode: result.authorizationCode, codeVerifier: result.codeVerifier, redirectUri: result.redirectUri, }), }); const session = await response.json(); console.log("Logged in:", session.name); // Access scope-requested claims from additionalClaims console.log("CPR:", result.additionalClaims["dk.cpr"]); } ``` You can check if the user is already authenticated before showing a sign-in button: ```javascript const loggedIn = await startiapp.Auth.isAuthenticated(); ``` See also [#see-also] Full API reference for the Auth module Authenticate users with Google Sign In Authenticate users with Apple Sign In Add Face ID / Touch ID resume to your OAuth login # Simulate the App in a Browser Simulate the App in a Browser [#simulate-the-app-in-a-browser] You can preview your app in Chrome as it would appear inside the native starti.app shell — no phone or emulator required. This works by adding a custom device profile in Chrome DevTools with the correct screen size and user agent. Add a custom device [#add-a-custom-device] Open DevTools settings [#open-devtools-settings] Open Chrome DevTools (`F12` or `Cmd + Option + I` on Mac) and click the **gear icon** in the top-right corner to open **Settings**. Go to Devices [#go-to-devices] In the sidebar, select **Devices**, then click **Add custom device…**. Configure the device [#configure-the-device] Enter the following values: | Field | Value | | ---------------------- | ------------------------ | | **Device name** | starti.app | | **Width** | 393 | | **Height** | 852 | | **Device pixel ratio** | 3 | | **User agent string** | `starti.app/999.999.999` | | **User agent type** | Mobile | Click **Add** to save the device. Use the custom device [#use-the-custom-device] Enable device mode [#enable-device-mode] Open DevTools and click the **Toggle device toolbar** button (or press `Cmd + Shift + M` on Mac / `Ctrl + Shift + M` on Windows). Select your device [#select-your-device] In the device dropdown at the top of the viewport, select **starti.app** from the list. Navigate to your app [#navigate-to-your-app] Go to your app's URL. The page will render at iPhone 14 Pro dimensions with the starti.app user agent, so the SDK will behave as if it is running inside the native app. Some native features (push notifications, biometrics, camera, etc.) are not available in the browser. This method is useful for previewing layout, styling, and SDK logic that does not depend on native bridges. # Use Geofencing Use Geofencing [#use-geofencing] Create and manage geofences to trigger actions when users enter or exit geographic areas. Prerequisites [#prerequisites] * The starti.app SDK is installed and initialized * Location access is granted Request location access [#request-location-access] ```javascript await startiapp.Location.requestAccess(); ``` Create a geofence [#create-a-geofence] Define a circular region with a center point and radius: ```javascript await startiapp.Location.createGeofence({ identifier: "office", center: { latitude: 55.6761, longitude: 12.5683, }, radius: { totalKilometers: 0.5, }, notifyOnEntry: true, notifyOnExit: true, }); ``` Listen for geofence events [#listen-for-geofence-events] ```javascript startiapp.Location.addEventListener("onRegionEntered", (event) => { console.log("Entered region:", event.detail.identifier); }); startiapp.Location.addEventListener("onRegionExited", (event) => { console.log("Exited region:", event.detail.identifier); }); ``` List active geofences [#list-active-geofences] ```javascript const geofences = await startiapp.Location.getGeofences(); console.log("Active geofences:", geofences); ``` Remove a geofence [#remove-a-geofence] ```javascript await startiapp.Location.removeGeofence({ identifier: "office", center: { latitude: 55.6761, longitude: 12.5683 }, radius: { totalKilometers: 0.5 }, }); ``` GeofenceRegion fields [#geofenceregion-fields] | Field | Type | Description | | --------------- | ------------------------- | -------------------------------------- | | `identifier` | `string` | Unique name for this geofence | | `center` | `{ latitude, longitude }` | Center point of the region | | `radius` | `{ totalKilometers }` | Radius in kilometers | | `notifyOnEntry` | `boolean` | Fire event when user enters (optional) | | `notifyOnExit` | `boolean` | Fire event when user exits (optional) | See also [#see-also] Full API reference for the Location module # Vibrate the Device Vibration [#vibration] Give users tactile feedback by triggering device vibration on interactions — for example, when scanning a QR code, completing an action, or tapping a button. Vibrate with JavaScript [#vibrate-with-javascript] Call `vibrate()` with an intensity level from 0 (low) to 3 (intense): ```javascript await startiapp.App.vibrate(0); // Low await startiapp.App.vibrate(1); // Medium await startiapp.App.vibrate(2); // High await startiapp.App.vibrate(3); // Intense ``` Use this when you want to control exactly when vibration happens — for example, when tapping a menu item, after validating a scanned QR code, or confirming a payment. Vibrate with CSS classes [#vibrate-with-css-classes] If you just want a button or other element to vibrate when tapped, you can skip JavaScript entirely. Add one of the vibration CSS classes to the element, and the SDK handles the rest: | Intensity | CSS classes | | --------- | ------------------------------------------------------ | | Low | `startiapp-v0`, `startiapp-v-l`, `startiapp-v-low` | | Medium | `startiapp-v1`, `startiapp-v-m`, `startiapp-v-medium` | | High | `startiapp-v2`, `startiapp-v-h`, `startiapp-v-high` | | Intense | `startiapp-v3`, `startiapp-v-i`, `startiapp-v-intense` | ```html ``` The vibration is triggered on click — no event listeners needed. The CSS classes work best with static HTML. If you add elements to the DOM dynamically (for example, rendering a list with JavaScript), the vibration classes may not be picked up. In that case, use the JavaScript `vibrate()` method instead. See also [#see-also] Full API reference for the vibrate method # Monetization Monetization [#monetization] How to sell access to content in your app — themes, chapters, levels, premium features, or anything else a user buys. Most "how do I sell X?" questions come down to **how you structure your store products**. There are only three product types under the hood (`nonconsumable`, `consumable`, `subscription`), but you can combine them into very different selling models. Picking the right one up front saves you from creating dozens of store products you didn't need — or from building a backend you could have avoided. Start here [#start-here] Compare the four models — per-item unlock, all-access unlock, content token, and all-access subscription — and find the one that fits your catalogue, pricing, and whether you have user accounts. A complete recipe for selling individual items, with the scale path when your catalogue grows. Key idea: store products vs. content items [#key-idea-store-products-vs-content-items] A **content item** is what the user thinks they're buying — one theme, one chapter. A **store product** is what you configure in App Store Connect / Google Play and the SDK purchases by ID. They are not always 1:1. With a content token, a single store product can sell an unlimited number of content items. Choosing your model is mostly about choosing that mapping. **Adding products does not require an app release.** A common worry: "do I have to release a new app version every time I add a theme?" No. After your first in-app purchase ships with an app version, you create and submit additional in-app purchase products for review on their own — no new binary required. See [Sell themes or one-off content](/sdk/monetization/sell-themes-or-one-off-content#you-dont-release-the-app-per-product). See also [#see-also] * [Set Up In-App Purchases](/sdk/how-to/setup-in-app-purchases) — the mechanical SDK steps * [In-App Purchase API Reference](/sdk/reference/in-app-purchase) * [Apple App Store](/sdk/stores/apple-app-store) · [Google Play Store](/sdk/stores/google-play-store) # Choosing a Monetization Model Choosing a Monetization Model [#choosing-a-monetization-model] There are four ways to sell content through the SDK. They differ on three things that matter to your app: how many **store products** you have to manage, whether the store can **restore** purchases for you, and whether you need your **own backend and user accounts**. Answer three questions and the table points you to a model. Answer these first [#answer-these-first] 1. **Do you have user accounts / login in the app?** (Needed to remember purchases across reinstalls when the store can't.) 2. **Do you add new content items often?** (Weekly drops vs. a stable catalogue.) 3. **Do you need per-item pricing — or would recurring revenue suit you better?** Start here [#start-here] | Your situation | Use | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Stable/small catalogue, you want to price and sell each item individually | **[Per-item unlock](#per-item-unlock)** | | You're happy selling everything as a single purchase | **[All-access unlock](#all-access-unlock)** | | Large or fast-growing catalogue, **and** you already have user accounts + a backend | **[Content token](#content-token)** | | You want recurring revenue and access to everything while subscribed | **[All-access subscription](#all-access-subscription)** | | Fast-growing catalogue but **no** accounts/backend | **[All-access unlock](#all-access-unlock)** or **[subscription](#all-access-subscription)** — avoid the token | The token model is the only one that demands your own backend. If you don't have user accounts, treat it as off the table — see the warning under [Content token](#content-token). The four models [#the-four-models] Per-item unlock [#per-item-unlock] One `nonconsumable` store product per content item. * **Store products:** one per item. * **Restore:** native — call `getOwnedProducts({ sync: true })`; the store remembers each purchase for free. * **Backend / accounts:** none required. * **Pick when:** your catalogue is stable or grows slowly and you want per-item pricing and individual ownership. * **Avoid when:** you add items constantly — creating and getting a product reviewed for every single item becomes a chore (though it never requires an app release; see the [recipe](/sdk/monetization/sell-themes-or-one-off-content#you-dont-release-the-app-per-product)). Bundles are a variant: a single `nonconsumable` that unlocks a *group* of items at one price. All-access unlock [#all-access-unlock] A single `nonconsumable` store product that unlocks **every** content item, present and future. * **Store products:** one, ever. * **Restore:** native — `getOwnedProducts({ sync: true })`. * **Backend / accounts:** none required. * **Pick when:** "buy everything, forever" is an acceptable offer and you want the simplest possible setup. New items light up automatically for everyone who bought. * **Avoid when:** you need to sell items individually or price them differently. Content token [#content-token] A `consumable` store product the user buys and then **redeems for any one content item**. One product (or a few price tiers) can back an unlimited catalogue. * **Store products:** one, or a handful of price tiers. * **Restore:** **none.** The store never restores consumables. * **Backend / accounts:** **required.** You must keep your own record of what each user owns, keyed to a user account, or purchases vanish on reinstall or a new device. * **Pick when:** you add items constantly and already run user accounts + a backend, so adding an item never touches App Store Connect or Google Play. * **Avoid when:** you have no login. Without an account to tie ownership to, a reinstall wipes the user's purchases and you can't restore them. **Tokens shift ownership onto you.** Because the store won't restore consumables, *you* become responsible for remembering who owns what. Apple still expects content users "expect across devices" to be restorable — with tokens that means your own account-backed restore. It's allowed, but it's real backend work. The SDK does **not** store entitlements for you. All-access subscription [#all-access-subscription] A `subscription` store product granting access to all content items while the subscription is active. * **Store products:** one (or a few tiers/periods). * **Restore:** native — `getActiveSubscriptions()` / `restorePurchases()`. * **Backend / accounts:** optional. The store reports active status; a backend only helps if you want server-authoritative gating. * **Pick when:** you want recurring revenue and "access while subscribed" fits the content. * **Avoid when:** users expect to *own* a one-off purchase forever. At a glance [#at-a-glance] | Model | Store products | Native restore | Needs your backend | | ----------------------- | -------------- | -------------- | ------------------ | | Per-item unlock | One per item | ✅ | No | | All-access unlock | One total | ✅ | No | | Content token | One (or tiers) | ❌ | **Yes** | | All-access subscription | One (or tiers) | ✅ | Optional | Next [#next] Ready to build? **[Sell themes or one-off content](/sdk/monetization/sell-themes-or-one-off-content)** walks through per-item unlock end-to-end, then shows the token upgrade path. See also [#see-also] * [Set Up In-App Purchases](/sdk/how-to/setup-in-app-purchases) * [In-App Purchase API Reference](/sdk/reference/in-app-purchase) # Sell Themes or One-Off Content Sell Themes or One-Off Content [#sell-themes-or-one-off-content] This recipe sells individual content items — themes, chapters, packs — as one-off purchases using the **per-item unlock** model. It's the fastest path to shipping: native restore, no backend required. When your catalogue outgrows it, jump to [Scaling up](#scaling-up-content-tokens). New here? Compare the options first in [Choosing a monetization model](/sdk/monetization/choosing-a-model). How per-item unlock works [#how-per-item-unlock-works] Each content item is one `nonconsumable` store product. The user buys it, the store remembers it forever, and `getOwnedProducts()` brings it back on a new device. You don't store anything. You don't release the app per product [#you-dont-release-the-app-per-product] The fear that prompts most people here: *"do we really create a product in Apple for every theme — and release the app each time?"* You create a product per item, yes. But you **do not release the app each time.** After your **first** in-app purchase ships attached to an app version, every additional product is created in App Store Connect / Google Play Console and submitted for review **on its own** — no new binary, no app update. Adding a theme is a store-console task, not an app release. Build it [#build-it] Create the store products [#create-the-store-products] For each item, create a **non-consumable** product: * **App Store Connect** → your app → In-App Purchases → create a non-consumable. * **Google Play Console** → your app → In-app products → create a managed product. Give each a product ID you can map back to the content item, e.g. `theme.aurora`, `theme.midnight`. Use the same ID on both stores so your code has one ID per item. See [Apple App Store](/sdk/stores/apple-app-store) and [Google Play Store](/sdk/stores/google-play-store) for the full store setup. Show the item with its localized price [#show-the-item-with-its-localized-price] Always read the price from the store — never hard-code it — so currencies and regional pricing are correct: ```javascript const productId = "theme.aurora"; const product = await startiapp.InAppPurchase.getProduct(productId, "nonconsumable"); if (product.success) { document.getElementById("theme-name").textContent = product.value.name; document.getElementById("theme-price").textContent = product.value.localizedPrice; } else { console.error("Couldn't load product:", product.errorMessage); } ``` Buy the item [#buy-the-item] ```javascript document.getElementById("buy-btn").addEventListener("click", async () => { const result = await startiapp.InAppPurchase.purchaseProduct( productId, "nonconsumable" ); if (!result.success) { console.error("Purchase failed:", result.errorMessage); return; } // Validate on your backend before unlocking — see the warning below. await fetch("/api/validate-purchase", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transactionId: result.value.transactionIdentifier, productId, }), }); unlockTheme(productId); }); ``` **Validate before you unlock.** Don't trust a client-side success alone. Send the transaction to your backend (or a store server-to-server check) and unlock only once it's verified. This is the one piece of server work per-item unlock asks of you, and it's optional for low-stakes content. Restore on a new device [#restore-on-a-new-device] Because these are non-consumables, the store restores them — no ledger of your own needed. Offer a "Restore purchases" button (the stores require one) and call `getOwnedProducts` with `{ sync: true }` to force a store sync: ```javascript document.getElementById("restore-btn").addEventListener("click", async () => { const result = await startiapp.InAppPurchase.getOwnedProducts({ sync: true }); if (result.success) { for (const product of result.value) { unlockTheme(product.productId); } } }); ``` `getOwnedProducts` returns non-consumables only. Subscriptions come from `getActiveSubscriptions`, and consumable **tokens** are never restored by the store — those you track in your own backend. Scaling up: content tokens [#scaling-up-content-tokens] Per-item unlock starts to hurt when you publish items constantly — every drop means a new store product and a review wait. At that point, switch to the **content token** model: * Sell one `consumable` "token" product (or a few price tiers). * The user buys a token and **redeems it for any item** — adding new items never touches the store consoles again. The catch: consumables are **never restored** by the store, so you must keep your **own record of what each user owns, tied to a user account**. No accounts, no token — a reinstall would wipe purchases you can't bring back. See [Content token](/sdk/monetization/choosing-a-model#content-token) for the trade-offs before committing. See also [#see-also] * [Choosing a monetization model](/sdk/monetization/choosing-a-model) * [Set Up In-App Purchases](/sdk/how-to/setup-in-app-purchases) * [In-App Purchase API Reference](/sdk/reference/in-app-purchase) # App **Access:** `startiapp.App` Methods [#methods] brandId(): Promise [#brandid-promisestring] Returns the brand identifier of the app. The result is cached after the first call. **Returns:** `Promise` —The brand ID string. **Example:** ```javascript const brandId = await startiapp.App.brandId(); // "example-brand" ``` *** deviceId(): Promise [#deviceid-promisestring] Returns a unique identifier for the current app installation. The ID changes if the user uninstalls and reinstalls the app. The result is cached after the first call. **Returns:** `Promise` — The installation ID (UUID format). **Example:** ```javascript const deviceId = await startiapp.App.deviceId(); // "00000000-0000-0000-0000-000000000000" ``` *** version(): Promise [#version-promisestring] Returns the app version string. The version format is `major.minor.patch`: * **Major** — a counter that increases when the minor number reaches 1000. * **Minor** — represents a specific commit in the starti.app codebase. * **Patch** — the build number, typically used when configuration or build-related changes are made without code changes. **Returns:** `Promise` — The version string. **Example:** ```javascript const version = await startiapp.App.version(); // "4.28.1" ``` *** platform [#platform] A read-only property that returns the platform the app is running on. **Returns:** `string` — `"android"`, `"ios"`, or `"web"`. **Example:** ```javascript const platform = startiapp.App.platform; // "android" | "ios" | "web" ``` *** isStartiappLoaded(): boolean [#isstartiapploaded-boolean] Returns whether the starti.app runtime has finished loading. **Returns:** `boolean` —`true` if the SDK `ready` event has fired. **Example:** ```javascript if (startiapp.App.isStartiappLoaded()) { console.log("SDK is ready"); } ``` *** supports(capability: AppCapability): boolean [#supportscapability-appcapability-boolean] Returns whether the app your page is running in is new enough for a given capability. Users update the app on their own schedule, so a page can be running inside an older version that does not have every feature yet. The SDK checks this for you: a method that depends on a newer app falls back to something sensible, so calling it is always safe — each method's description says what it does instead. Ask directly when the *page* should look different — for example to leave out a sign-in choice the app in front of you cannot carry out yet. **Parameters:** | Parameter | Type | Required | Description | | ---------- | --------------- | -------- | ----------------------- | | capability | `AppCapability` | Yes | The capability to check | **Returns:** `boolean` — `true` if the app supports it. Always `false` outside the app, where there is no app version to compare against. **Example:** ```javascript if (startiapp.App.supports("signInOptions")) { showMitIdWithSsnButton(); } ``` *** addInternalDomain(domain: string): Promise [#addinternaldomaindomain-string-promisevoid] For a practical guide to domain handling — including when to use `handleAllDomainsInternally()` and the difference between the in-app browser and the device's browser — see [Handle Domains](/sdk/how-to/handle-domains). Registers a domain as internal. Internal domains are loaded inside the app's webview. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------- | | domain | `string` | Yes | The domain to register as internal | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.addInternalDomain("example.com"); ``` *** removeInternalDomain(domain: string): Promise [#removeinternaldomaindomain-string-promisevoid] Removes a domain from the internal domains list. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------- | | domain | `string` | Yes | The domain to remove | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.removeInternalDomain("example.com"); ``` *** getInternalDomains(): Promise [#getinternaldomains-promisestring] Returns the list of registered internal domains. **Returns:** `Promise` —Array of internal domain strings. **Example:** ```javascript const domains = await startiapp.App.getInternalDomains(); console.log(domains); // ["example.com", "api.example.com"] ``` *** addExternalDomains(...domains: RegExp[]): Promise [#addexternaldomainsdomains-regexp-promisevoid] Registers domains as external using regex patterns. A URL matching an external pattern opens in the device's browser (Safari, Chrome), outside the app — this takes precedence over internal domains. Domains that are neither internal nor external open in the in-app browser. **Parameters:** | Parameter | Type | Required | Description | | ---------- | ---------- | -------- | ---------------------------------------------------- | | ...domains | `RegExp[]` | Yes | One or more regex patterns matching external domains | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.addExternalDomains(/example\.com/, /other\.org/); ``` *** removeExternalDomains(...domains: RegExp[]): Promise [#removeexternaldomainsdomains-regexp-promisevoid] Removes domains from the external domains list. **Parameters:** | Parameter | Type | Required | Description | | ---------- | ---------- | -------- | ---------------------------- | | ...domains | `RegExp[]` | Yes | The regex patterns to remove | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.removeExternalDomains(/example\.com/); ``` *** getExternalDomains(): Promise [#getexternaldomains-promiseregexdto] Returns the list of registered external domain patterns. **Returns:** `Promise` —Array of regex pattern objects. **Example:** ```javascript const externalDomains = await startiapp.App.getExternalDomains(); console.log(externalDomains); // [{ pattern: "example\\.com", flags: "" }] ``` *** handleAllDomainsInternally(): Promise [#handlealldomainsinternally-promisevoid] Treats all domains as internal, except those explicitly added as external. This is a session-only setting and resets on app restart. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.handleAllDomainsInternally(); ``` *** restoreDefaultDomainHandling(): Promise [#restoredefaultdomainhandling-promisevoid] Restores the default domain handling behavior, where unknown domains open in the in-app browser. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.restoreDefaultDomainHandling(); ``` *** openExternalBrowser(url: string): Promise [#openexternalbrowserurl-string-promisevoid] Opens a URL in the device's system browser. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------- | | url | `string` | Yes | The URL to open | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.openExternalBrowser("https://example.com"); ``` *** setStatusBar(options: SetStatusBarOptions): void [#setstatusbaroptions-setstatusbaroptions-void] Configures the status bar appearance. Options are **merged** onto the current status bar configuration, so you can update a single property at a time without resetting the rest. `advancedSafeAreaOptions` is merged recursively (per side, then per property). Set `darkContent: "auto"` to let the app pick the content colour automatically from the configured safe area background colour's brightness. `"auto"` does not inspect the actual pixels or CSS background behind the status bar. If you remove the safe area, set `darkContent` explicitly to `true` or `false` so the status bar stays readable over your page content. **Parameters:** | Parameter | Type | Required | Description | | --------- | --------------------------------------------- | -------- | -------------------------------------------------------------------- | | options | [`SetStatusBarOptions`](#setstatusbaroptions) | Yes | Status bar configuration (partial — merged onto the current options) | **Example:** ```javascript // Full configuration startiapp.App.setStatusBar({ hideText: false, darkContent: true, removeSafeArea: false, safeAreaBackgroundColor: "#ffffff", }); // Update only the content colour — everything else is kept startiapp.App.setStatusBar({ darkContent: "auto" }); ``` *** hideStatusBar(): Promise [#hidestatusbar-promisevoid] Hides the device status bar. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.hideStatusBar(); ``` *** showStatusBar(): Promise [#showstatusbar-promisevoid] Shows the device status bar. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.showStatusBar(); ``` *** setSafeAreaBackgroundColor(color: string): Promise [#setsafeareabackgroundcolorcolor-string-promisevoid] Sets the background color of the safe area (notch / home indicator region). Only the background colour changes. The content (text/icon) colour follows the `darkContent` setting: when it is `"auto"` (the default) the content colour is recomputed from the new background's brightness, and an explicit `true`/`false` choice is left untouched. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------- | | color | `string` | Yes | CSS color value | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.setSafeAreaBackgroundColor("#000000"); ``` *** setSpinner(options: SpinnerOptions): Promise [#setspinneroptions-spinneroptions-promisevoid] Configures the navigation loading spinner. This is particularly useful for traditional multi-page applications where clicking a link loads an entirely new HTML page. Since the app does not show a progress bar or other loading indicator during page navigation, the spinner makes it clear that new content is being loaded. By default, the spinner only appears after 250 milliseconds, so it stays hidden if the page loads quickly. **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------------------------------- | -------- | --------------------- | | options | [`SpinnerOptions`](#spinneroptions) | Yes | Spinner configuration | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.setSpinner({ show: true, color: "#3498db", afterMilliseconds: 300, excludedDomains: ["cdn.example.com"], }); ``` *** showSpinner(options?: SpinnerOptions): Promise [#showspinneroptions-spinneroptions-promisevoid] Shows the navigation loading spinner, optionally with configuration. **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------------------------------- | -------- | ------------------------------ | | options | [`SpinnerOptions`](#spinneroptions) | No | Optional spinner configuration | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.showSpinner(); // With options await startiapp.App.showSpinner({ show: true, color: "#e74c3c", afterMilliseconds: 500, excludedDomains: [], }); ``` *** hideSpinner(): Promise [#hidespinner-promisevoid] Hides the navigation loading spinner. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.hideSpinner(); ``` *** pushOptions(options: InitializeParams): void [#pushoptionsoptions-initializeparams-void] Pushes a set of UI options onto the options stack. This lets you temporarily override app options and restore them later with `popOptions()`. **Parameters:** | Parameter | Type | Required | Description | | --------- | --------------------------------------- | -------- | ------------------- | | options | [`InitializeParams`](#initializeparams) | Yes | The options to push | **Example:** ```javascript startiapp.App.pushOptions({ allowZoom: false, allowRotation: false, statusBar: { hideText: true, darkContent: false, removeSafeArea: true, }, }); ``` *** popOptions(): void [#popoptions-void] Pops the most recent options from the options stack, restoring the previous state. **Example:** ```javascript startiapp.App.popOptions(); ``` *** enableScreenRotation(): Promise [#enablescreenrotation-promisevoid] Enables the device screen to rotate with the device orientation. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.enableScreenRotation(); ``` *** disableScreenRotation(): Promise [#disablescreenrotation-promisevoid] Locks the screen orientation, preventing rotation. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.disableScreenRotation(); ``` *** enableSwipeNavigation(): Promise [#enableswipenavigation-promisevoid] Enables swipe gestures for back/forward navigation. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.enableSwipeNavigation(); ``` *** disableSwipeNavigation(): Promise [#disableswipenavigation-promisevoid] Disables swipe gestures for navigation. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.disableSwipeNavigation(); ``` *** openSettings(): Promise [#opensettings-promisevoid] Opens the device settings page for the app. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.openSettings(); ``` *** setAppIcon(iconName: string): Promise [#setappiconiconname-string-promisevoid] Changes the app's home screen icon to one of the alternate icons that are built into the app. The available icons are configured in the starti.app Manager and bundled when the app is built. Adding new icons requires publishing a new version of the app through Apple and Google's review process — you cannot add icons dynamically at runtime. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------------------------- | | iconName | `string` | Yes | Name of the alternate icon (must match one returned by `getAvailableIcons()`) | **Returns:** `Promise` **Example:** ```javascript const icons = await startiapp.App.getAvailableIcons(); // ["default", "dark-icon", "holiday-icon"] await startiapp.App.setAppIcon("dark-icon"); ``` *** getCurrentIcon(): Promise [#getcurrenticon-promisestring] Returns the name of the currently active app icon. **Returns:** `Promise` —The current icon name. **Example:** ```javascript const currentIcon = await startiapp.App.getCurrentIcon(); console.log(currentIcon); ``` *** getAvailableIcons(): Promise [#getavailableicons-promisestring] Returns the list of available alternate app icons. **Returns:** `Promise` —Array of icon names. **Example:** ```javascript const icons = await startiapp.App.getAvailableIcons(); console.log(icons); // ["default", "dark-icon", "holiday-icon"] ``` *** setSplashVariant(name: string | null): void [#setsplashvariantname-string--null-void] Chooses which of the brand's splash animations the app opens with, **from the next launch onwards**. Pass `null` to go back to the default one. A brand that ships more than one splash animation names each of them in the starti.app Manager, under **Alternative animations** on the splash screen page — see [Splash screen](/en/academy/manager/development-setup/splash-screen#alternative-animations). This call selects one by that name. The choice is stored on the device, so it survives launches and app updates until your page changes it again; it is per installation, not per signed-in user, so clear it yourself on sign-out if that is what you mean. This never affects the splash that is on screen right now. The app builds its splash before your page exists, so by the time you could answer "which animation?", one has already played. Call it when you learn something that should change the *next* opening — after a sign-in, say — not when you want the splash to change now. Nothing is validated and nothing is reported back. A name the app does not have — an older build, or a variant the brand has since removed — opens the default animation rather than failing, which is what makes it safe to call during a rollout. **Parameters:** | Parameter | Type | Required | Description | | --------- | ---------------- | -------- | -------------------------------------------------------------------------------- | | name | `string \| null` | Yes | The variant name from the Manager, or `null` to go back to the default animation | **Returns:** `void` — the choice is recorded natively; there is nothing to await. **Requires:** app version 4.229.0 — check with [`supports("splashVariants")`](#supportscapability-appcapability-boolean). Older apps and browsers ignore the call, and those users keep seeing the default animation. **Example:** ```javascript // After sign-in: returning users open on the short loop startiapp.App.setSplashVariant("returning"); // After sign-out: back to the animation everyone else gets startiapp.App.setSplashVariant(null); ``` *** getAppUrl(): Promise [#getappurl-promisestring] Returns the currently configured app URL. **Returns:** `Promise` —The app URL. **Example:** ```javascript const url = await startiapp.App.getAppUrl(); ``` *** setAppUrl(url: string): Promise [#setappurlurl-string-promisevoid] Sets the app's base URL. The app will load this URL on next launch. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------- | | url | `string` | Yes | The URL to set | **Returns:** `Promise` **Example:** ```javascript await startiapp.App.setAppUrl("https://example.com"); ``` *** resetAppUrl(): Promise [#resetappurl-promisevoid] Resets the app URL to its default value. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.resetAppUrl(); ``` *** vibrate(intensity: VibrationIntensity): Promise [#vibrateintensity-vibrationintensity-promisevoid] For a practical guide to vibration — including CSS classes that trigger vibration without JavaScript — see [Vibration](/sdk/how-to/vibration). Triggers device vibration with the specified intensity. **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------------------------- | -------- | ----------------------------- | | intensity | [`VibrationIntensity`](#vibrationintensity) | Yes | The vibration intensity level | **Returns:** `Promise` **Example:** ```javascript // VibrationIntensity values: 0 = Low, 1 = Medium, 2 = High, 3 = Intense await startiapp.App.vibrate(1); // Medium ``` *** requestReview(): Promise [#requestreview-promisevoid] Prompts the user to rate the app in the app store. The system controls when and whether the prompt is actually shown. This only works in the production version of the app (downloaded from the App Store or Google Play). During development and testing, the review prompt will not appear. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.requestReview(); ``` *** requestAppTracking(): Promise [#requestapptracking-promisevoid] Prompts the user to allow app tracking (iOS App Tracking Transparency). The easiest way to trigger this is by adding a terms-and-conditions step to the Intro Flow in the starti.app Manager. The tracking prompt is then shown automatically when the user accepts the terms — no custom JavaScript needed. **Returns:** `Promise` **Example:** ```javascript await startiapp.App.requestAppTracking(); ``` *** Events [#events] navigatingPage [#navigatingpage] Fired when the app is navigating to a new page. **Event data:** [`NavigatingPageEvent`](#navigatingpageevent) **Example:** ```javascript startiapp.App.addEventListener("navigatingPage", (event) => { console.log("Navigating to:", event.detail.url); console.log("Opens external:", event.detail.opensExternalbrowser); }); ``` *** appInForeground [#appinforeground] Fired when the app comes back to the foreground (e.g. user switches back to the app). **Event data:** `void` **Example:** ```javascript startiapp.App.addEventListener("appInForeground", () => { console.log("App is back in foreground"); // Refresh data, reconnect sockets, etc. }); ``` *** splashHidden [#splashhidden] Fired when the app's splash screen has gone and your page is what the user is looking at. **Sticky:** a listener added after it happened still fires. That is the normal case rather than the exception — the splash is taken down within a frame or two of the SDK becoming ready, so even a listener added from your own `ready` handler can be too late, and being too late would look exactly like an event that never arrives. **Event data:** `void` **Example:** ```javascript startiapp.App.addEventListener("splashHidden", () => { // Nothing before now was on screen for the user to see. playTheOpeningAnimation(); }); ``` *** splashAnimationCompleted [#splashanimationcompleted] Fired when the splash screen's animation has played its last frame. Only ever fired for a brand whose splash animation plays once — a looping one has no last frame — and it is independent of [`splashHidden`](#splashhidden): depending on the brand's splash settings the animation may finish well before the splash goes, or the splash may go first and this never fire at all. Sticky, like `splashHidden`. **Event data:** `void` **Example:** ```javascript startiapp.App.addEventListener("splashAnimationCompleted", () => { // Hand over from the splash artwork without cutting it off. revealTheHero(); }); ``` Neither event is sent by an app shell older than the build that introduced them, and there is no capability to ask. Treat them as an enhancement rather than something to gate rendering on. *** keyboardOpened [#keyboardopened] Fired when the software keyboard comes up, covering part of the screen. The app resizes the web view to the room the keyboard leaves, so the top of your page stays where it was and the bottom of the viewport rises with the keyboard. Your layout has already reflowed by the time this fires — listen when something has to *react* to the keyboard (scroll a message list back to the bottom, hide a floating button), not to make room for it. Fired on the transition only: a keyboard that grows a predictive bar or swaps to emoji while it is already up does not fire it again. For styling alone you do not need this event: the SDK carries the same state on `` as the [`startiapp-keyboard-open`](/sdk/getting-started/setup-and-the-basics#the-keyboard-open-attribute) attribute. **Event data:** [`KeyboardOpenedEvent`](#keyboardopenedevent) **Example:** ```javascript startiapp.App.addEventListener("keyboardOpened", (event) => { console.log("Keyboard covers", event.detail.height, "px"); scrollMessagesToBottom(); }); ``` *** keyboardClosed [#keyboardclosed] Fired when the software keyboard goes away and the page has its full height back. The [`startiapp-keyboard-open`](/sdk/getting-started/setup-and-the-basics#the-keyboard-open-attribute) attribute is off `` again by the time your listener runs. **Event data:** `void` **Example:** ```javascript startiapp.App.addEventListener("keyboardClosed", () => { restoreTheFloatingButton(); }); ``` *** Types [#types] AppCapability [#appcapability] The capabilities [`supports()`](#supportscapability-appcapability-boolean) can be asked about. Each one arrived in a specific app version; older apps answer `false`. ```typescript type AppCapability = // signIn() accepts an options argument (requireSsn, scope) — app 3.992.0 | "signInOptions" // openSecureBrowser() presents the OS browser sheet over the app — app 4.184.0 | "secureBrowser" // setStatusBar() state can be replaced as well as patched, which is what lets // popOptions() undo a pushOptions() — app 4.205.0 | "statusBarState" // setSplashVariant() records which splash animation the next launch opens with — app 4.229.0 | "splashVariants"; ``` KeyboardOpenedEvent [#keyboardopenedevent] ```typescript interface KeyboardOpenedEvent { /** * How much of the screen the software keyboard covers, in CSS pixels. The app has already * shrunk the web view to the room that is left, so this is what the page lost, not a * margin it has to leave for itself. */ height: number; } ``` NavigatingPageEvent [#navigatingpageevent] ```typescript interface NavigatingPageEvent { url: string; opensExternalbrowser: boolean; } ``` VibrationIntensity [#vibrationintensity] ```typescript enum VibrationIntensity { Low = 0, Medium = 1, High = 2, Intense = 3, } ``` SetStatusBarOptions [#setstatusbaroptions] Every property is optional. The app merges the properties you provide onto the current status bar settings, so you can change a single property at a time (for example only `darkContent`) without resetting the rest. ```typescript type SetStatusBarOptions = SafeAreaSideOptions & { hideText?: boolean; // true = dark content (for light backgrounds) // false = light content (for dark backgrounds) // "auto" (default) = chosen from the configured safe area background colour's brightness // If removeSafeArea is true, set this explicitly because web content is not inspected. darkContent?: boolean | "auto"; advancedSafeAreaOptions?: AdvancedSafeAreaOptions; }; ``` SafeAreaSideOptions [#safeareasideoptions] ```typescript type SafeAreaSideOptions = { removeSafeArea?: boolean; // defaults to false safeAreaBackgroundColor?: string; // defaults to "#FFFFFF" }; ``` AdvancedSafeAreaOptions [#advancedsafeareaoptions] ```typescript interface AdvancedSafeAreaOptions { top?: SafeAreaSideOptions; bottom?: SafeAreaSideOptions; } ``` SpinnerOptions [#spinneroptions] ```typescript interface SpinnerOptions { afterMilliseconds: number; show: boolean; color: string; excludedDomains: string[]; } ``` InitializeParams [#initializeparams] ```typescript interface InitializeParams { allowZoom?: boolean; allowRotation?: boolean; allowDrag?: boolean; allowScrollBounce?: boolean; allowHighligt?: boolean; allowSwipeNavigation?: boolean; spinner?: Partial; statusBar?: SetStatusBarOptions; } ``` **Prefer the Manager for these.** All of these options can be configured for your brand under [Initialization settings](https://docs.starti.app/en/academy/manager/development-setup/initialization-settings) in the [starti.app Manager](https://manager.starti.app/initialization-settings), and that is the recommended place to set them — they then apply to your whole app with no code. Options are resolved in four layers, each merged onto the one before it property by property: | Layer | Set by | Changed by | | ------------------------------------------------------------ | -------------------------------------- | -------------------------------------------------------------- | | SDK defaults | The SDK | — | | Brand settings | Initialization settings in the Manager | A publish in the Manager, live within about 15 minutes | | `initialize()` | Your page | A release of your website | | [`pushOptions()`](#pushoptionsoptions-initializeparams-void) | Your page, per screen | At runtime, and undone with [`popOptions()`](#popoptions-void) | Because the layers merge property by property, passing a partial object — say `statusBar: { darkContent: true }` — overrides only that property and keeps the rest of the brand's configuration. Set an option in code when it can only be decided at runtime, for example a status bar that follows the user's dark mode, or a screen that needs different gestures than the rest of the app. Property-by-property merging of the nested `statusBar` and `spinner` objects only applies to brands that have initialization settings in the Manager. For a brand without them, passing a partial `statusBar` replaces the object wholesale. RegexDto [#regexdto] ```typescript interface RegexDto { pattern: string; flags: string; } ``` # Auth **Access:** `startiapp.Auth` For step-by-step guides to setting up authentication — see [Sign in with Google](/sdk/how-to/sign-in-with-google), [Sign in with Apple](/sdk/how-to/sign-in-with-apple), and [Sign in with MitID](/sdk/how-to/sign-in-with-mitid). To add biometric resume to your OAuth flow, see [Biometric Login with OAuth](/sdk/how-to/biometric-oauth-login). Methods [#methods] signIn(providerName, options?): Promise [#signinprovidername-options-promiseauthenticationoutcome] Starts the sign-in flow for the given provider. Returns the authentication result when the flow completes. **Parameters:** | Parameter | Type | Required | Description | | ------------ | --------------------------------------------------- | -------- | ---------------------------- | | providerName | [`AuthenticationProvider`](#authenticationprovider) | Yes | The provider to sign in with | | options | [`SignInOptions`](#signinoptions) | No | Additional sign-in options | **Returns:** `Promise>` —Either an [`AuthenticationResult`](#authenticationresult) (or [`AuthenticationResultApple`](#authenticationresultapple) for Apple) on success, or an [`AuthenticationFailure`](#authenticationfailure) on failure. **Example:** ```typescript // Basic sign in with Google const result = await startiapp.Auth.signIn("google"); if (result.isSuccess) { console.log("Authorization code:", result.authorizationCode); console.log("Redirect URI:", result.redirectUri); } else { console.error("Sign in failed:", result.errorMessage); } ``` ```typescript // Sign in with Apple (includes identity info) const result = await startiapp.Auth.signIn("apple"); if (result.isSuccess) { console.log("User ID:", result.identity.userId); console.log("Email:", result.identity.email); console.log("ID Token:", result.identity.idToken); // Name is only available on the first sign-in console.log("Name:", result.identity.name); } ``` ```typescript // Sign in with MitID and request SSN const result = await startiapp.Auth.signIn("signaturgruppenmitid", { scope: "openid mitid ssn ssn.details_name", }); if (result.isSuccess) { console.log("Claims:", result.additionalClaims); } ``` *** signOut(): Promise [#signout-promiseboolean] Signs out the current user. **Returns:** `Promise` —`true` if sign out was successful. **Example:** ```typescript const success = await startiapp.Auth.signOut(); console.log("Signed out:", success); ``` *** getCurrentSession(): Promise [#getcurrentsession-promiseauthenticationresult--null] Returns the current authentication session, or `null` if the user is not authenticated. **Returns:** `Promise` **Example:** ```typescript const session = await startiapp.Auth.getCurrentSession(); if (session) { console.log("Logged in as provider:", session.providerName); } else { console.log("Not authenticated"); } ``` *** isAuthenticated(): Promise [#isauthenticated-promiseboolean] Checks whether a user is currently authenticated. **Returns:** `Promise` —`true` if authenticated. **Example:** ```typescript const loggedIn = await startiapp.Auth.isAuthenticated(); if (loggedIn) { console.log("User is logged in"); } ``` *** Types [#types] AuthenticationProvider [#authenticationprovider] The built-in provider names, or any custom string. ```typescript type AuthenticationProvider = | "apple" | "google" | "microsoft" | "signaturgruppenmitid" | (string & {}); ``` SignInOptions [#signinoptions] ```typescript interface SignInOptions { scope?: string; } ``` AuthenticationOutcome [#authenticationoutcome] A discriminated union — either a success result or a failure. ```typescript type AuthenticationOutcome = | AuthenticationResult // generic success | AuthenticationResultApple // when T is "apple" | AuthenticationFailure; ``` Check `isSuccess` to discriminate: ```typescript const result = await startiapp.Auth.signIn("google"); if (result.isSuccess) { // result is AuthenticationResult } else { // result is AuthenticationFailure } ``` AuthenticationSuccessBase [#authenticationsuccessbase] Base interface shared by all successful authentication results. ```typescript interface AuthenticationSuccessBase { isSuccess: true; providerName: T; additionalClaims: Record; } ``` AuthenticationResult [#authenticationresult] The standard success result for most providers. ```typescript interface AuthenticationResult extends AuthenticationSuccessBase { authorizationCode: string; codeVerifier: string; redirectUri: string; } ``` AuthenticationResultApple [#authenticationresultapple] The success result specific to Apple sign-in. Includes an `identity` object with user details. ```typescript interface AuthenticationResultApple extends AuthenticationSuccessBase<"apple"> { authorizationCode: string; redirectUri: string; identity: { userId: string; email: string; /** Only available on the first sign-in. */ name: string | null; idToken: string; }; } ``` AuthenticationFailure [#authenticationfailure] Returned when sign-in fails or is cancelled. ```typescript interface AuthenticationFailure { isSuccess: false; providerName: T; errorMessage: string; } ``` # Biometrics **Access:** `startiapp.Biometrics` For step-by-step guides to implementing biometric login — see [Biometric Login](/sdk/how-to/biometric-login) and [Biometric Login with OAuth](/sdk/how-to/biometric-oauth-login). Methods [#methods] scan(title?, reason?): Promise [#scantitle-reason-promiseboolean] Triggers a biometric scan (fingerprint or face recognition) and returns whether it succeeded. **Parameters:** | Parameter | Type | Required | Default | Description | | --------- | -------- | -------- | ---------------------------------- | --------------------------------------- | | title | `string` | No | `"Prove you have fingers!"` | The title shown in the biometric prompt | | reason | `string` | No | `"Can't let you in if you don't."` | The reason/subtitle shown in the prompt | **Returns:** `Promise` —`true` if the scan was successful. **Example:** ```typescript const success = await startiapp.Biometrics.scan( "Verify Identity", "Please authenticate to continue" ); if (success) { console.log("Biometric scan passed"); } ``` *** getAuthenticationType(): Promise [#getauthenticationtype-promisebiometricsauthenticationtype] Returns the type of biometric authentication available on the device. **Returns:** `Promise` —`"face"`, `"fingerprint"`, or `"none"`. **Example:** ```typescript const type = await startiapp.Biometrics.getAuthenticationType(); if (type === "face") { console.log("Face ID available"); } else if (type === "fingerprint") { console.log("Fingerprint available"); } else { console.log("No biometrics available"); } ``` *** checkAccess(): Promise [#checkaccess-promisepermissionstatus] Checks the current permission status for biometrics on the device. **Returns:** `Promise` —Object with a `granted` boolean. **Example:** ```typescript const status = await startiapp.Biometrics.checkAccess(); if (status.granted) { console.log("Biometrics access granted"); } ``` *** setSecuredContent(content: unknown): Promise [#setsecuredcontentcontent-unknown-promisevoid] Saves arbitrary content to the device's secure keychain storage. The content is JSON-serialized before storing. **Parameters:** | Parameter | Type | Required | Description | | --------- | --------- | -------- | ------------------------------------------- | | content | `unknown` | Yes | The data to store (will be JSON-serialized) | **Returns:** `Promise` **Example:** ```typescript await startiapp.Biometrics.setSecuredContent({ apiToken: "abc123", refreshToken: "xyz789", }); ``` *** getSecuredContent(title?, reason?): Promise [#getsecuredcontentttitle-reason-promiset--null] Retrieves previously stored secured content. Triggers a biometric scan to authorize access. **Parameters:** | Parameter | Type | Required | Default | Description | | --------- | -------- | -------- | ---------------------------------- | --------------------------- | | title | `string` | No | `"Prove you have fingers!"` | The biometric prompt title | | reason | `string` | No | `"Can't let you in if you don't."` | The biometric prompt reason | **Returns:** `Promise` —The deserialized content, or `null` if nothing is stored or parsing fails. **Example:** ```typescript interface Tokens { apiToken: string; refreshToken: string; } const tokens = await startiapp.Biometrics.getSecuredContent( "Access Tokens", "Authenticate to retrieve your saved tokens" ); if (tokens) { console.log("API Token:", tokens.apiToken); } ``` *** hasSecuredContent(): Promise [#hassecuredcontent-promiseboolean] Checks whether secured content exists in the keychain without triggering a biometric scan. **Returns:** `Promise` —`true` if secured content exists. **Example:** ```typescript const hasContent = await startiapp.Biometrics.hasSecuredContent(); if (hasContent) { console.log("Secured content is stored"); } ``` *** removeSecuredContent(): Promise [#removesecuredcontent-promisevoid] Removes the secured content from the keychain. **Returns:** `Promise` **Example:** ```typescript await startiapp.Biometrics.removeSecuredContent(); ``` *** setUsernameAndPassword(username, password): Promise [#setusernameandpasswordusername-password-promisevoid] Saves a username and password pair to the device's secure keychain. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------------- | | username | `string` | Yes | The username to store | | password | `string` | Yes | The password to store | **Returns:** `Promise` **Example:** ```typescript await startiapp.Biometrics.setUsernameAndPassword("john@example.com", "s3cret"); ``` *** getUsernameAndPassword(title?, reason?): Promise<{ username: string; password: string } | null> [#getusernameandpasswordtitle-reason-promise-username-string-password-string---null] Retrieves the saved username and password. Triggers a biometric scan to authorize access. **Parameters:** | Parameter | Type | Required | Default | Description | | --------- | -------- | -------- | ---------------------------------- | --------------------------- | | title | `string` | No | `"Prove you have fingers!"` | The biometric prompt title | | reason | `string` | No | `"Can't let you in if you don't."` | The biometric prompt reason | **Returns:** `Promise<{ username: string; password: string } | null>` —The credentials, or `null` if none are stored. **Example:** ```typescript const credentials = await startiapp.Biometrics.getUsernameAndPassword( "Login", "Authenticate to auto-fill your credentials" ); if (credentials) { console.log("Username:", credentials.username); // Auto-fill the login form document.querySelector("#username")!.value = credentials.username; document.querySelector("#password")!.value = credentials.password; } ``` *** hasUsernameAndPassword(): Promise [#hasusernameandpassword-promiseboolean] Checks whether a username and password pair is stored without triggering a biometric scan. **Returns:** `Promise` —`true` if credentials are stored. **Example:** ```typescript const hasCreds = await startiapp.Biometrics.hasUsernameAndPassword(); if (hasCreds) { console.log("Saved credentials available — show biometric login button"); } ``` *** removeUsernameAndPassword(): Promise [#removeusernameandpassword-promisevoid] Removes the saved username and password from the keychain. **Returns:** `Promise` **Example:** ```typescript await startiapp.Biometrics.removeUsernameAndPassword(); ``` *** startSaveUsernameAndPassword(request): Promise [#startsaveusernameandpasswordrequest-promisevoid] Begins the automatic credential capture flow. This attaches middleware to a login form's submit button that captures the username and password when the user submits the form. After a successful login, call [`endSaveUsernameAndPassword()`](#endsaveusernameandpasswordconfig-promisevoid) to finalize saving. **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------------------------------------------------------------- | -------- | ------------------------------------------------- | | request | [`SaveUsernameAndPasswordConfiguration`](#saveusernameandpasswordconfiguration) | Yes | Configuration for form selectors and localization | **Returns:** `Promise` **Example:** ```typescript await startiapp.Biometrics.startSaveUsernameAndPassword({ usernameInputFieldSelector: "#username", passwordInputFieldSelector: "#password", submitButtonSelector: "#login-button", title: "Save Login", reason: "Save your credentials for quick login next time", language: "en", }); ``` *** endSaveUsernameAndPassword(config?): Promise [#endsaveusernameandpasswordconfig-promisevoid] Completes the credential capture flow. Call this after a successful login. It will prompt the user (if they have not previously consented) to save credentials using biometrics, then store them if the user agrees. **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------------------------------------------------------------------- | -------- | ------------------------------------------ | | config | [`EndSaveUsernameAndPasswordConfiguration`](#endsaveusernameandpasswordconfiguration) | No | Optional key to identify the configuration | **Returns:** `Promise` **Example:** ```typescript // After successful login await startiapp.Biometrics.endSaveUsernameAndPassword(); ``` *** Types [#types] BiometricsAuthenticationType [#biometricsauthenticationtype] ```typescript type BiometricsAuthenticationType = "none" | "face" | "fingerprint"; ``` PermissionStatus [#permissionstatus] ```typescript interface PermissionStatus { granted: boolean; } ``` SaveUsernameAndPasswordConfiguration [#saveusernameandpasswordconfiguration] Full configuration for the automated credential capture flow. Combines form selectors with internationalization settings. ```typescript type SaveUsernameAndPasswordConfiguration = { key?: string; } & SaveUsernameAndPasswordConfigurationSelectors & SaveUsernameAndPasswordI18n; ``` SaveUsernameAndPasswordConfigurationSelectors [#saveusernameandpasswordconfigurationselectors] ```typescript type SaveUsernameAndPasswordConfigurationSelectors = { usernameInputFieldSelector: string; passwordInputFieldSelector: string; submitButtonSelector: string; executingButtonSelector?: string; }; ``` SaveUsernameAndPasswordI18n [#saveusernameandpasswordi18n] Internationalization options for the biometric consent dialog. Provide one of three options: 1. A built-in `language` (`"da"` or `"en"`) 2. A `confirmMessageTemplate` with optional biometrics type translations 3. Full custom `translations` ```typescript type SaveUsernameAndPasswordI18n = { title: string; reason: string; } & ( | { language?: "da" | "en" } | { confirmMessageTemplate: string; biometricsTypeTranslations?: { face: string; fingerprint: string; }; } | { translations: { nextTime: { title: string; subtitle: string; acceptedButtonText: string; declinedButtonText: string; }; biometricsType: { face: string; fingerprint: string; }; }; } ); ``` EndSaveUsernameAndPasswordConfiguration [#endsaveusernameandpasswordconfiguration] ```typescript type EndSaveUsernameAndPasswordConfiguration = { key?: string; }; ``` BiometricsResultResponse [#biometricsresultresponse] ```typescript type BiometricsResultResponse = { key: string; result: T; }; ``` # Camera & Image Capture **Access:** Standard HTML — no SDK method required. For a practical guide to capturing images — see [Capture Images](/sdk/how-to/capture-images). The starti.app container supports the standard HTML file input for capturing images and videos. When the user taps a file input with the `capture` attribute, the native camera opens directly. Without `capture`, a chooser appears that lets the user pick from the gallery **or** take a new photo/video. This works because the native container (Android and iOS) intercepts the webview's file chooser request and presents the appropriate native UI — no special SDK call is needed. Capture an image [#capture-an-image] Use a standard `` with `accept="image/*"` and the `capture` attribute: ```html ``` The `capture` attribute values: | Value | Camera | | --------------- | ---------------------------- | | `"user"` | Front-facing camera | | `"environment"` | Rear-facing camera (default) | JavaScript example [#javascript-example] ```javascript function captureImage() { return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.capture = "environment"; input.addEventListener("change", () => { const file = input.files?.[0]; if (file) { resolve(file); } }); input.click(); }); } // Usage const imageFile = await captureImage(); console.log("Captured:", imageFile.name, imageFile.size); // Display the captured image const url = URL.createObjectURL(imageFile); document.getElementById("preview").src = url; ``` Capture a video [#capture-a-video] ```html ``` Accept multiple file types [#accept-multiple-file-types] ```html ``` Upload the captured file [#upload-the-captured-file] ```javascript async function captureAndUpload() { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.capture = "environment"; const file = await new Promise((resolve) => { input.addEventListener("change", () => resolve(input.files?.[0])); input.click(); }); if (!file) return; const formData = new FormData(); formData.append("photo", file); const response = await fetch("/api/upload", { method: "POST", body: formData, }); console.log("Upload status:", response.status); } ``` Camera permissions [#camera-permissions] Camera access requires user permission. The native container handles the permission prompt automatically when the user interacts with a file input that needs the camera. On iOS (15+), the webview grants media capture permission automatically. If you also use the [QR Scanner](/sdk/reference/qr-scanner), the camera permission requested there applies to file inputs as well — and vice versa. Platform notes [#platform-notes] | Behavior | Android | iOS | | ----------------------- | -------------------------------- | -------------------------------- | | `capture` attribute | Opens camera directly | Opens camera directly | | No `capture` attribute | Shows chooser (gallery + camera) | Shows chooser (gallery + camera) | | Full-resolution photos | Yes | Yes | | Multiple file selection | Yes | Yes | This approach uses standard web APIs, so it also works when your page is opened in a regular mobile browser — not just inside the starti.app container. See also [#see-also] Scan QR codes using the device camera with the SDK's built-in scanner # External Purchase This module relies on Apple StoreKit APIs and is only available on iOS devices that support the External Purchase entitlement. **Access:** `startiapp.ExternalPurchaseCustomLink` Methods [#methods] getTokens(): Promise [#gettokens-promisetokensresponse] Retrieves acquisition and services tokens from StoreKit. These tokens are required when redirecting the user to your external purchase page so Apple can attribute the transaction. This method is also called automatically when a user ID is registered via `startiapp.User.registerId()`. **Returns:** [`Promise`](#tokensresponse) — An object containing token data and eligibility status. **Example:** ```typescript const response = await startiapp.ExternalPurchaseCustomLink.getTokens(); if (response.isEligible && response.tokens) { const acquisitionToken = response.tokens["ACQUISITION"]?.rawToken; console.log("Acquisition token:", acquisitionToken); } ``` *** showNotice(): Promise [#shownotice-promisenoticeresponse] Displays the Apple-mandated disclosure notice that must be shown before redirecting a user to an external purchase page. The notice informs the user they are about to leave the app to make a purchase. **Returns:** [`Promise`](#noticeresponse) — An object containing the user's response to the notice and token data. **Example:** ```typescript const response = await startiapp.ExternalPurchaseCustomLink.showNotice(); if (response.status === "continued") { // User accepted the notice, proceed with external purchase window.location.href = "https://example.com/purchase"; } else { console.log("User cancelled the notice"); } ``` *** getCountryCode(): Promise [#getcountrycode-promisestring--null] Returns the App Store country code for the current user's account (e.g., `"US"`, `"DK"`). Returns `null` if the information is unavailable. **Returns:** `Promise` —ISO 3166-1 alpha-2 country code, or `null`. **Example:** ```typescript const country = await startiapp.ExternalPurchaseCustomLink.getCountryCode(); console.log("Store country:", country); // e.g., "DK" ``` *** canMakePayments(): Promise [#canmakepayments-promiseboolean] Checks whether the device is able to make payments (i.e., in-app purchases are not restricted by parental controls or device policy). **Returns:** `Promise` —`true` if the device can make payments. **Example:** ```typescript const allowed = await startiapp.ExternalPurchaseCustomLink.canMakePayments(); if (!allowed) { console.log("Payments are restricted on this device"); } ``` *** show(url): Promise [#showurl-promiseboolean] Convenience method that combines `showNotice()` and a redirect. It displays the Apple disclosure notice, and if the user accepts (status is `"accepted"` or `"not-applicable"`), the browser is redirected to the provided URL. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------------------- | | url | `string` | Yes | The external purchase URL to redirect to if the user accepts. | **Returns:** `Promise` —`true` if the redirect was performed, `false` if the user declined the notice. **Example:** ```typescript const redirected = await startiapp.ExternalPurchaseCustomLink.show( "https://example.com/purchase?plan=premium" ); if (!redirected) { console.log("User chose not to proceed"); } ``` Events [#events] userTokensReceived [#usertokensreceived] Fired when new purchase tokens are received from StoreKit. The event's `detail` property contains the tokens dictionary. **Example:** ```typescript startiapp.ExternalPurchaseCustomLink.addEventListener( "userTokensReceived", (event) => { const tokens = event.detail; console.log("Tokens received:", tokens); } ); ``` Types [#types] TokensResponse [#tokensresponse] Response from `getTokens()` containing acquisition and services tokens. ```typescript type TokensResponse = { /** Whether the app is eligible for external purchase custom links. */ isEligible: boolean; /** Dictionary of tokens by type (e.g., "ACQUISITION", "SERVICES"). */ tokens?: Record; /** Error code if the request failed. */ error?: string; /** Human-readable error message. */ message?: string; /** Raw response string if deserialization failed. */ raw?: string; }; ``` *** NoticeResponse [#noticeresponse] Response from `showNotice()` after displaying the external purchase disclosure. ```typescript type NoticeResponse = { /** Whether the app is eligible for external purchase custom links. */ isEligible: boolean; /** Dictionary of tokens by type (e.g., "ACQUISITION", "SERVICES"). */ tokens?: Record; /** Status of the user's interaction: "continued", "cancelled", or "unknown". */ status?: string; /** Error code if the request failed. */ error?: string; /** Human-readable error message. */ message?: string; /** Raw response string if deserialization failed. */ raw?: string; }; ``` *** TokenInfo [#tokeninfo] Information about a single token received from StoreKit. ```typescript type TokenInfo = { /** The raw Base64URL-encoded token string. */ rawToken?: string; /** Indicates whether the token was successfully decoded. */ decoded?: boolean; /** Error message if token retrieval failed. */ error?: string; /** Any additional decoded data from the token. */ additionalData?: Record; }; ``` # Gestures **Access:** `startiapp.Gesture` The app has its own swipe navigation — left-to-right for back, right-to-left for forward, both on by default. It runs the equivalent of `history.back()` and `history.forward()`, which in a single-page app is a full document navigation: the page jumps, or reloads, and whatever the user was looking at is gone. `startiapp.Gesture` tells your page about the swipe instead, so you can run your own transition. ```typescript await startiapp.initialize({ allowSwipeNavigation: false }); startiapp.Gesture.addEventListener("swipe-back", () => { myRouter.back(); }); ``` Listening for a gesture does **not** disable the app's swipe navigation, and the app's version covers both directions. Turn it off yourself, or one swipe gives the user your transition *and* a document navigation underneath it. See [Turning the app's gesture off](#turning-the-apps-gesture-off). Events [#events] swipe-back [#swipe-back] The user swiped left-to-right, the direction both iOS and Android reserve for going back. **Event data:** `GestureDetail` ```typescript startiapp.Gesture.addEventListener("swipe-back", (event) => { myRouter.back(); }); ``` *** swipe-forward [#swipe-forward] The user swiped right-to-left. The mirror of `swipe-back`, carrying the same `GestureDetail`. ```typescript startiapp.Gesture.addEventListener("swipe-forward", () => { wizard.next(); }); ``` Types [#types] GestureDetail [#gesturedetail] ```typescript interface GestureDetail { distance: number; duration: number; velocity: number; startX: number; startY: number; target: EventTarget | null; } ``` | Property | Type | Description | | -------- | --------------------- | ----------------------------------------------------------------------------------------------------------------- | | distance | `number` | How far the finger travelled horizontally, in CSS pixels. Always positive — the event name carries the direction. | | duration | `number` | Milliseconds between the finger going down and coming up. | | velocity | `number` | Speed in CSS pixels per millisecond, or `0` if the whole gesture landed in the same millisecond. | | startX | `number` | Where the finger went down, in viewport coordinates. | | startY | `number` | Where the finger went down, in viewport coordinates. | | target | `EventTarget \| null` | The element the finger went down on. | Usage Patterns [#usage-patterns] Turning the app's gesture off [#turning-the-apps-gesture-off] For a site that routes with `history.pushState`, the app's swipe navigation is wrong everywhere, so turn it off once for the whole app. **Prefer the Manager for this.** `allowSwipeNavigation` is one of the initialization settings you can set for your brand under **Development setup → Initialization settings**, which keeps it out of your code. Pass it to `initialize()` only if the value has to be decided at runtime. ```typescript await startiapp.initialize({ allowSwipeNavigation: false }); ``` If it is wrong only on one screen — a carousel, a map, a signature pad — push it for that screen and pop it again on the way out. See [`App.pushOptions`](/sdk/reference/app). ```typescript startiapp.App.pushOptions({ allowSwipeNavigation: false }); // Leaving the screen again. startiapp.App.popOptions(); ``` Only from the screen edge [#only-from-the-screen-edge] iOS starts a back gesture only near the edge. `startX` is what lets you match that, and it is also the cheapest way to stop a swipe meant for something else on the page from navigating. ```typescript startiapp.Gesture.addEventListener("swipe-back", (event) => { if (event.detail.startX > 40) return; myRouter.back(); }); ``` Ignoring a swipe that belongs to something else [#ignoring-a-swipe-that-belongs-to-something-else] ```typescript startiapp.Gesture.addEventListener("swipe-back", (event) => { const target = event.detail.target as Element | null; if (target?.closest(".carousel")) return; myRouter.back(); }); ``` Removing the listener with a screen [#removing-the-listener-with-a-screen] The recognizer is started when the first listener for a gesture arrives and stopped when the last one goes, so a page that never mentions gestures carries no touch listeners for them. Remove your listener when the screen it belongs to goes away, and an `AbortSignal` is the tidiest way — one controller tears down every listener the screen added. ```typescript const screen = new AbortController(); startiapp.Gesture.addEventListener("swipe-back", () => myRouter.back(), { signal: screen.signal, }); // When the screen is torn down — this also stops the recognizer. screen.abort(); ``` Platform notes [#platform-notes] A touch becomes a swipe only if all of these hold: * it travelled at least **60 px** horizontally, * it travelled at least **twice** as far horizontally as it did vertically — so scrolling the page and drifting sideways never counts, * it took no longer than **800 ms** — a slow drag is not a flick, * it was a single finger the whole way. A second finger landing cancels it, and so does the browser taking the touch over (a pinch-zoom, a native scroll). The direction is geometric, not reading-order: left-to-right is always `swipe-back`, in a right-to-left layout too. Two limits are worth knowing before you design around these events: * **The gesture is known when the finger lifts,** not while it moves. There is deliberately no per-frame work here, which is what makes the recognizer nearly free — but it means you cannot drive a transition that follows the finger from these events. Track touches yourself for that. * **Touch only.** A trackpad or mouse drag in a desktop browser fires nothing. Use a phone, or a browser's device-emulation mode, to try it. See also [#see-also] * [App — `pushOptions`, `enableSwipeNavigation`, `disableSwipeNavigation`](/sdk/reference/app) * [Control the app UI](/sdk/how-to/control-app-ui) # In-App Purchase **Access:** `startiapp.InAppPurchase` For step-by-step guides — see [Setup In-App Purchases](/sdk/how-to/setup-in-app-purchases) and [Setup Subscriptions](/sdk/how-to/setup-subscriptions). Methods [#methods] purchaseProduct(productId, purchaseType): Promise> [#purchaseproductproductid-purchasetype-promiseinapppurchaseresponseinapppurchaseproductresponse] Initiates a native purchase flow for the specified product. The returned promise resolves after the user completes or cancels the transaction. **Parameters:** | Parameter | Type | Required | Description | | ------------ | ------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | productId | `string` | Yes | The product identifier as configured in App Store Connect or Google Play Console. | | purchaseType | [`InApPurchasePurchaseType`](#inappurchasepurchasetype) | Yes | Whether the product is `"consumable"` or `"nonconsumable"`. | **Returns:** `Promise>` — A response object indicating success (with a transaction identifier) or failure (with an error message). **Example:** ```typescript const result = await startiapp.InAppPurchase.purchaseProduct( "com.example.premium", "nonconsumable" ); if (result.success) { console.log("Transaction ID:", result.value.transactionIdentifier); } else { console.error("Purchase failed:", result.errorMessage); } ``` *** getProduct(productId, purchaseType): Promise> [#getproductproductid-purchasetype-promiseinapppurchaseresponseinapppurchasegetproductresponse] Retrieves product metadata (name, description, localized price) from the store without initiating a purchase. **Parameters:** | Parameter | Type | Required | Description | | ------------ | ------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | productId | `string` | Yes | The product identifier as configured in App Store Connect or Google Play Console. | | purchaseType | [`InApPurchasePurchaseType`](#inappurchasepurchasetype) | Yes | Whether the product is `"consumable"` or `"nonconsumable"`. | **Returns:** `Promise>` — A response object containing product details on success, or an error message on failure. **Example:** ```typescript const result = await startiapp.InAppPurchase.getProduct( "com.example.premium", "nonconsumable" ); if (result.success) { console.log(`${result.value.name} - ${result.value.localizedPrice}`); } else { console.error("Failed to load product:", result.errorMessage); } ``` *** subscribe(productId, options?): Promise> [#subscribeproductid-options-promiseinapppurchaseresponsesubscriberesponse] Initiates a subscription purchase. Also handles upgrades and downgrades when `options.oldProductId` is provided. **Parameters:** | Parameter | Type | Required | Description | | --------- | --------------------------------------- | -------- | ------------------------------------------------------- | | productId | `string` | Yes | The subscription product identifier. | | options | [`SubscribeOptions`](#subscribeoptions) | No | Options for upgrades/downgrades and promotional offers. | **Returns:** `Promise>` — Transaction details on success. **Example:** ```typescript // New subscription const result = await startiapp.InAppPurchase.subscribe("com.example.monthly"); if (result.success) { console.log("Subscribed! Transaction:", result.value.transactionId); // IMPORTANT: Validate on your backend first, then finish the transaction const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(result.value) }).then(r => r.ok); if (validated) { await startiapp.InAppPurchase.finishTransaction(result.value.transactionId); } } ``` You **must** call `finishTransaction()` after your backend validates the purchase. If you don't, the store will re-deliver the transaction on next app launch and may eventually issue a refund. **Upgrade example:** ```typescript // Upgrade from monthly to yearly const result = await startiapp.InAppPurchase.subscribe("com.example.yearly", { oldProductId: "com.example.monthly", upgradePolicy: "immediate" }); ``` *** finishTransaction(transactionId): Promise> [#finishtransactiontransactionid-promiseinapppurchaseresponseboolean] Finishes (acknowledges) a subscription transaction after your backend has validated it. **This must be called after every successful `subscribe()` call** once your server confirms the purchase. If not called, the transaction remains "unfinished" — the store will re-deliver it on next app launch (via `getActiveSubscriptions()` / `restorePurchases()`), and may eventually refund the user. **Parameters:** | Parameter | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------- | | transactionId | `string` | Yes | The `transactionId` from the `SubscribeResponse`. | **Returns:** `Promise>` — `true` if the transaction was acknowledged. **Example:** ```typescript // After your backend confirms the subscription is valid: const result = await startiapp.InAppPurchase.finishTransaction(transactionId); if (result.success) { console.log("Transaction acknowledged"); } ``` *** getSubscriptionProducts(productIds): Promise> [#getsubscriptionproductsproductids-promiseinapppurchaseresponsesubscriptionproduct] Retrieves subscription product details including pricing, billing period, and available offers. **Parameters:** | Parameter | Type | Required | Description | | ---------- | ---------- | -------- | ------------------------------------------ | | productIds | `string[]` | Yes | Array of subscription product identifiers. | **Returns:** `Promise>` — Array of subscription product details. On Android, only the first introductory pricing phase is returned per product. If a subscription has a multi-phase intro (e.g., free trial followed by a discounted period), only the first phase appears in `introductoryOffer`. **Example:** ```typescript const result = await startiapp.InAppPurchase.getSubscriptionProducts([ "com.example.monthly", "com.example.yearly" ]); if (result.success) { result.value.forEach(product => { console.log(`${product.name}: ${product.localizedPrice}/${product.subscriptionPeriod}`); if (product.introductoryOffer) { console.log(` Trial: ${product.introductoryOffer.localizedPrice} for ${product.introductoryOffer.period}`); } }); } ``` *** getActiveSubscriptions(options?): Promise> [#getactivesubscriptionsoptions-promiseinapppurchaseresponsesubscriptionstatus] Returns the status of all active subscriptions for the current user. Use this to check if the user has an active subscription and its current state. **Parameters:** | Parameter | Type | Required | Description | | ------------ | --------- | -------- | -------------------------------------------------------------------------------------------------- | | options.sync | `boolean` | No | Force sync with App Store / Google Play before returning. Use for restore purchases functionality. | **Returns:** `Promise>` — Array of active subscription statuses. **Example:** ```typescript const result = await startiapp.InAppPurchase.getActiveSubscriptions(); if (result.success) { const active = result.value.filter(s => s.state === "subscribed"); if (active.length > 0) { console.log("User has active subscription:", active[0].productId); console.log("Will renew:", active[0].willAutoRenew); } } ``` *** getOwnedProducts(options?): Promise> [#getownedproductsoptions-promiseinapppurchaseresponseownedproduct] Returns the non-consumable products the user currently owns. Use this to restore one-off purchases (e.g. unlocked themes or content) on a new device or after a reinstall — it is the non-consumable counterpart to `getActiveSubscriptions()`. Consumable purchases are never returned by the store; track those in your own backend. **Parameters:** | Parameter | Type | Required | Description | | ------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | options.sync | `boolean` | No | Force sync with App Store / Google Play before returning. Use for a "Restore Purchases" button. Defaults to `false`. | **Returns:** `Promise>` — Array of owned non-consumable products on success. **Example:** ```typescript // On a new device / after reinstall: restore owned themes const result = await startiapp.InAppPurchase.getOwnedProducts({ sync: true }); if (result.success) { for (const product of result.value) { unlockTheme(product.productId); } } ``` *** restorePurchases(): Promise> [#restorepurchases-promiseinapppurchaseresponsesubscriptionstatus] Syncs with the store and returns all subscriptions — including expired ones. Unlike `getActiveSubscriptions()` which only returns active subscriptions, `restorePurchases()` returns the full history so you can re-validate on your backend. Required by App Store guidelines — apps with subscriptions must include a "Restore Purchases" button. `restorePurchases()` covers **subscriptions** only. To restore owned **non-consumables** (one-off purchases), use [`getOwnedProducts()`](#getownedproductsoptions-promiseinapppurchaseresponseownedproduct). **Returns:** `Promise>` — Array of subscription statuses (may include both `"subscribed"` and `"expired"`). **Example:** ```typescript const result = await startiapp.InAppPurchase.restorePurchases(); if (result.success) { console.log("Restored", result.value.length, "subscriptions"); } ``` *** manageSubscriptions(): Promise> [#managesubscriptions-promiseinapppurchaseresponseboolean] Opens the platform's subscription management screen where the user can cancel, change, or view their subscriptions. On iOS, this opens an in-app management sheet. On Android, this opens the Google Play subscriptions page in an external browser. **Returns:** `Promise>` — `true` if the management screen was opened successfully. **Example:** ```typescript await startiapp.InAppPurchase.manageSubscriptions(); ``` *** isSubscribed(productId?): Promise [#issubscribedproductid-promiseboolean] Convenience wrapper over `getActiveSubscriptions()`. Returns `true` if the user has at least one subscription with `state === "subscribed"`. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------------ | | productId | `string` | No | If provided, checks for this specific product ID only. | **Returns:** `Promise` — `true` if the user has an active subscription (or the specific product if `productId` is provided). Returns `false` on error rather than throwing. **Example:** ```typescript // Check for any active subscription if (await startiapp.InAppPurchase.isSubscribed()) { unlockPremiumFeatures(); } // Check for a specific product const hasYearly = await startiapp.InAppPurchase.isSubscribed("com.example.yearly"); ``` *** Events [#events] "unfinishedTransaction" [#unfinishedtransaction] Emitted automatically on app startup for each subscription transaction that was purchased but not yet finished (acknowledged). This handles cases where the app crashed, lost connectivity, or closed before `finishTransaction()` was called. The listener can be registered before or after `startiapp.initialize()` — unfinished transactions are buffered and delivered when the first listener is added. **Event detail:** [`SubscriptionStatus`](#subscriptionstatus) **Example:** ```typescript await startiapp.initialize(); startiapp.InAppPurchase.addEventListener("unfinishedTransaction", async (event) => { const transaction = event.detail; // Validate on your backend, then finish the transaction const validated = await fetch("/api/validate-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(transaction) }).then(r => r.ok); if (validated) { await startiapp.InAppPurchase.finishTransaction(transaction.transactionId); } }); ``` On Android, Google Play gives you **3 days** to acknowledge a purchase before it is automatically refunded. Types [#types] InApPurchasePurchaseType [#inappurchasepurchasetype] Union type specifying the kind of in-app purchase product. ```typescript type InApPurchasePurchaseType = "nonconsumable" | "consumable"; ``` | Value | Description | | ----------------- | ---------------------------------------------------------------------- | | `"nonconsumable"` | A one-time purchase that does not expire (e.g., unlock a feature). | | `"consumable"` | A purchase that can be bought multiple times (e.g., in-game currency). | *** InAppPurchaseResponse [#inapppurchaseresponset] Discriminated union returned by all purchase methods. Check the `success` field to determine which shape you have. ```typescript type InAppPurchaseResponse = | { success: true; value: T } | { success: false; errorMessage: string }; ``` *** InAppPurchaseProductResponse [#inapppurchaseproductresponse] Payload returned on a successful `purchaseProduct` call. ```typescript type InAppPurchaseProductResponse = { transactionIdentifier: string; }; ``` *** InAppPurchaseGetProductResponse [#inapppurchasegetproductresponse] Payload returned on a successful `getProduct` call. ```typescript type InAppPurchaseGetProductResponse = { name: string; description: string; localizedPrice: string; currencyCode: string; }; ``` *** OwnedProduct [#ownedproduct] A non-consumable product the user owns, returned by `getOwnedProducts`. ```typescript type OwnedProduct = { /** The product identifier as configured in the store. */ productId: string; /** Unique identifier for the owning transaction. */ transactionId: string; /** ISO 8601 purchase date. */ purchaseDate: string; }; ``` *** SubscribeOptions [#subscribeoptions] Options for the `subscribe` method. All fields are optional. ```typescript type SubscribeOptions = { oldProductId?: string; upgradePolicy?: "immediate" | "deferred"; offerId?: string; resolveOffer?: (productId: string, offerId: string) => Promise; }; ``` | Field | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `oldProductId` | Product ID of the subscription being replaced. Android only — on iOS, StoreKit 2 handles same-group upgrades automatically. On Android, the SDK auto-detects the active subscription unless your app has multiple subscription groups. | | `upgradePolicy` | `"immediate"` charges now and switches. `"deferred"` switches at end of current period. Android only — iOS handles transitions automatically within subscription groups. | | `offerId` | Promotional offer identifier as configured in App Store Connect or Google Play Console. On iOS, pair with `resolveOffer` for promotional offers that require a server-signed signature. On Android, this is passed as the offer token. | | `resolveOffer` | Async callback that returns the offer signature from your backend. Only called when needed (iOS promotional offers). Ignored on Android. | *** OfferSignature [#offersignature] Returned by the `resolveOffer` callback. Your backend must generate these values using your App Store Connect subscription key. ```typescript type OfferSignature = { signature: string; nonce: string; timestamp: string; keyId: string; }; ``` *** SubscribeResponse [#subscriberesponse] Payload returned on a successful `subscribe` call. **Important:** The transaction is not yet finished — you must call `finishTransaction()` after your backend validates it. ```typescript type SubscribeResponse = { /** Pass this to finishTransaction() after backend validation. */ transactionId: string; originalTransactionId: string; productId: string; purchaseDate: string; }; ``` *** SubscriptionProduct [#subscriptionproduct] Subscription product details returned by `getSubscriptionProducts`. ```typescript type SubscriptionProduct = { productId: string; name: string; description: string; localizedPrice: string; currencyCode: string; /** Human-readable billing period, e.g. "1 month", "1 year", "3 months". null if not available. */ subscriptionPeriod: string | null; introductoryOffer?: SubscriptionOffer; promotionalOffers?: SubscriptionOffer[]; }; ``` *** SubscriptionOffer [#subscriptionoffer] Details of a subscription offer (introductory or promotional). ```typescript type SubscriptionOffer = { id?: string; localizedPrice: string; period: string; periodCount: number; paymentMode: "freeTrial" | "payAsYouGo" | "payUpFront"; }; ``` | `paymentMode` | Description | | -------------- | ------------------------------------------------------------------- | | `"freeTrial"` | Free for the offer period, then regular price. | | `"payAsYouGo"` | Discounted price charged each period for `periodCount` periods. | | `"payUpFront"` | Discounted price charged once up front for the full offer duration. | *** SubscriptionStatus [#subscriptionstatus] Status of an active or expired subscription. ```typescript type SubscriptionStatus = { productId: string; transactionId: string; originalTransactionId: string; purchaseDate: string; state: "subscribed" | "expired"; willAutoRenew: boolean; }; ``` | `state` | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `"subscribed"` | Active and in good standing. Includes users in grace period or billing retry — the SDK handles this automatically. | | `"expired"` | Subscription has ended, been revoked, or payment failed beyond grace period. | # Intro flow **Access:** `startiapp.IntroFlow` By default the intro flow opens by itself as soon as the app loads, and you never have to touch this. Turning **Trigger** on in the starti.app Manager holds it back instead: nothing appears until you call [`show()`](#show-promiseboolean). That setting moves the trigger point and nothing else. Which steps a given user still has to see, in what order, and what each one does is decided exactly the same way either way. Available on apps built with factory v2. On an older bundle both methods answer `false` and log a warning explaining that the app needs rebuilding. Methods [#methods] show(): Promise [#show-promiseboolean] Shows the intro flow now. **Returns:** `true` once the flow is on screen. `false` if there was nothing to show — this user has already been through every step — or if a flow is already up. Resolving does not mean the user finished the flow, only that it was put in front of them. Listen for the [`introFlowClosed`](#introflowclosed) event to know when they are done with it. **Example:** ```typescript startiapp.addEventListener("ready", async () => { await signIn(); await startiapp.IntroFlow.show(); }); ``` *** isPending(): Promise [#ispending-promiseboolean] Whether calling `show()` right now would put anything on screen — so `false` both when this user has seen every step and when a flow is already up. Answering needs the record of what this user has already seen, which lives in native storage, hence the promise. Nothing is shown as a side effect of asking. **Returns:** `true` if this user still has steps left to see. **Example:** ```typescript // Only dim the page for an intro flow that is actually going to appear. if (await startiapp.IntroFlow.isPending()) { document.body.classList.add("dimmed"); await startiapp.IntroFlow.show(); } ``` Events [#events] The flow dispatches these on `document` rather than on `startiapp.IntroFlow`, so they reach you wherever it happens to be mounted. introFlowLoaded [#introflowloaded] Fired once the flow has worked out what this user still has to see and put it on screen. **Event data:** `void` **Example:** ```javascript document.addEventListener("introFlowLoaded", () => { console.log("The intro flow is up"); }); ``` *** introFlowClosed [#introflowclosed] Fired when the user has been through the last step and the flow has been taken off screen. **Event data:** `void` **Example:** ```javascript document.addEventListener("introFlowClosed", () => { startTheTour(); }); ``` # Location **Access:** `startiapp.Location` Methods [#methods] getLocation(options?): Promise [#getlocationoptions-promiselocation] Retrieves the device's current location. **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------------------- | -------- | ------------------------------------------ | | options | [`LocationOptions`](#locationoptions) | No | Options for timeout, caching, and accuracy | **Returns:** `Promise` —The current [location](#location-1). **Example:** ```typescript // Basic usage const location = await startiapp.Location.getLocation(); console.log(location.latitude, location.longitude); ``` ```typescript // With options const location = await startiapp.Location.getLocation({ timeoutInMs: 10000, maxAgeInMs: 5000, desiredAccuracy: "High", }); console.log(`Lat: ${location.latitude}, Lng: ${location.longitude}`); console.log(`Accuracy: ${location.accuracy}m`); ``` *** startLocationListener(options?): Promise [#startlocationlisteneroptions-promisevoid] Starts continuously listening for location changes. Location updates are delivered via the [`locationChanged`](#locationchanged) event. **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------------------------------------- | -------- | ----------------------------------------- | | options | [`LocationListeningOptions`](#locationlisteningoptions) | No | Options for update frequency and accuracy | **Returns:** `Promise` **Example:** ```typescript // Listen for location updates startiapp.Location.addEventListener("locationChanged", (event) => { const loc = event.detail; console.log(`Moved to: ${loc.latitude}, ${loc.longitude}`); }); // Start listening with high accuracy, minimum 1 second between updates await startiapp.Location.startLocationListener({ minimumTimeInMs: 1000, desiredAccuracy: "High", }); ``` *** stopLocationListener(): Promise [#stoplocationlistener-promisevoid] Stops the location listener started by `startLocationListener()`. **Returns:** `Promise` **Example:** ```typescript await startiapp.Location.stopLocationListener(); ``` *** isLocationListenerActive(): Promise [#islocationlisteneractive-promiseboolean] Checks whether the location listener is currently running. **Returns:** `Promise` —`true` if listening. **Example:** ```typescript const active = await startiapp.Location.isLocationListenerActive(); if (active) { console.log("Location tracking is active"); } ``` *** createGeofence(region): Promise [#creategeofenceregion-promisevoid] Creates a geofence region. When the user enters or exits the region, the corresponding event fires. **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------------------------------- | -------- | ----------------------------- | | region | [`GeofenceRegion`](#geofenceregion) | Yes | The geofence region to create | **Returns:** `Promise` **Example:** ```typescript await startiapp.Location.createGeofence({ identifier: "office", center: { latitude: 55.6761, longitude: 12.5683 }, radius: { totalKilometers: 0.5 }, notifyOnEntry: true, notifyOnExit: true, }); ``` *** removeGeofence(region): Promise [#removegeofenceregion-promisevoid] Removes a previously created geofence. **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------------------------------- | -------- | ----------------------------------------------------- | | region | [`GeofenceRegion`](#geofenceregion) | Yes | The geofence region to remove (matched by identifier) | **Returns:** `Promise` **Example:** ```typescript await startiapp.Location.removeGeofence({ identifier: "office", center: { latitude: 55.6761, longitude: 12.5683 }, radius: { totalKilometers: 0.5 }, }); ``` *** getGeofences(): Promise [#getgeofences-promisegeofenceregion] Returns all currently registered geofences. **Returns:** `Promise` **Example:** ```typescript const geofences = await startiapp.Location.getGeofences(); geofences.forEach((fence) => { console.log(`${fence.identifier}: ${fence.center.latitude}, ${fence.center.longitude}`); }); ``` *** requestAccess(): Promise [#requestaccess-promisevoid] Requests location permission from the user. Shows the platform's native permission dialog. **Returns:** `Promise` **Example:** ```typescript await startiapp.Location.requestAccess(); ``` *** checkAccess(): Promise [#checkaccess-promisepermissionstatus] Checks the current location permission status without prompting. **Returns:** `Promise` —Object with a `granted` boolean. **Example:** ```typescript const status = await startiapp.Location.checkAccess(); if (!status.granted) { await startiapp.Location.requestAccess(); } ``` *** Events [#events] locationChanged [#locationchanged] Fired when the device location changes while the location listener is active. **Event data:** [`Location`](#location-1) **Example:** ```typescript startiapp.Location.addEventListener("locationChanged", (event) => { const location = event.detail; console.log(`New position: ${location.latitude}, ${location.longitude}`); console.log(`Speed: ${location.speed} m/s`); console.log(`Mock provider: ${location.isFromMockProvider}`); }); ``` *** onRegionEntered [#onregionentered] Fired when the user enters a geofence region. **Event data:** [`GeofenceRegion`](#geofenceregion) **Example:** ```typescript startiapp.Location.addEventListener("onRegionEntered", (event) => { console.log(`Entered region: ${event.detail.identifier}`); }); ``` *** onRegionExited [#onregionexited] Fired when the user exits a geofence region. **Event data:** [`GeofenceRegion`](#geofenceregion) **Example:** ```typescript startiapp.Location.addEventListener("onRegionExited", (event) => { console.log(`Exited region: ${event.detail.identifier}`); }); ``` *** Types [#types] Location Type [#location-type] ```typescript interface Location { latitude: number; longitude: number; altitude?: number | null; accuracy?: number | null; altitudeAccuracy?: number | null; reducedAccuracy: boolean; heading?: number | null; speed?: number | null; isFromMockProvider: boolean; timestamp: string; } ``` LocationOptions [#locationoptions] ```typescript interface LocationOptions { /** Maximum time in milliseconds to wait for a location update. */ timeoutInMs?: number; /** Maximum age in milliseconds of a cached location that is acceptable. */ maxAgeInMs?: number; /** The desired accuracy of the location. */ desiredAccuracy?: LocationAccuracy; } ``` LocationListeningOptions [#locationlisteningoptions] ```typescript interface LocationListeningOptions { /** Minimum time in milliseconds between location updates. */ minimumTimeInMs?: number; /** The desired accuracy of the location updates. */ desiredAccuracy?: LocationAccuracy; } ``` LocationAccuracy [#locationaccuracy] ```typescript enum LocationAccuracy { /** Default accuracy (Medium), typically 30-500 meters. */ Default = "Default", /** Lowest accuracy, least power, typically 1000-5000 meters. */ Lowest = "Lowest", /** Low accuracy, typically 300-3000 meters. */ Low = "Low", /** Medium accuracy, typically 30-500 meters. */ Medium = "Medium", /** High accuracy, typically 10-100 meters. */ High = "High", /** Best accuracy, most power, typically within 10 meters. */ Best = "Best", } ``` GeofenceRegion [#geofenceregion] ```typescript interface GeofenceRegion { identifier: string; center: Position; radius: Distance; notifyOnEntry?: boolean; notifyOnExit?: boolean; } ``` Position [#position] ```typescript interface Position { latitude: number; longitude: number; } ``` Distance [#distance] ```typescript interface Distance { totalKilometers: number; } ``` GeofenceState [#geofencestate] ```typescript type GeofenceState = "entered" | "exited" | "unknown"; ``` PermissionStatus [#permissionstatus] ```typescript interface PermissionStatus { granted: boolean; } ``` # Media **Access:** `startiapp.Media` Methods [#methods] setVolume(newVolume): Promise [#setvolumenewvolume-promisevoid] Sets the system volume to the specified value. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------------- | | newVolume | `number` | Yes | Target volume level between `0` (muted) and `1` (maximum). | **Returns:** `Promise` —Resolves when the volume has been set. **Example:** ```typescript // Set volume to 50 % await startiapp.Media.setVolume(0.5); ``` *** getVolume(): Promise [#getvolume-promisenumber] Gets the current system volume. **Returns:** `Promise` —The current volume level between `0` and `1`. **Example:** ```typescript const volume = await startiapp.Media.getVolume(); console.log(`Current volume: ${volume * 100}%`); ``` *** increaseVolume(): Promise [#increasevolume-promisevoid] Increases the system volume by one step (the same increment as pressing the hardware volume-up button). **Returns:** `Promise` —Resolves when the volume has been increased. **Example:** ```typescript await startiapp.Media.increaseVolume(); ``` *** decreaseVolume(): Promise [#decreasevolume-promisevoid] Decreases the system volume by one step (the same decrement as pressing the hardware volume-down button). **Returns:** `Promise` —Resolves when the volume has been decreased. **Example:** ```typescript await startiapp.Media.decreaseVolume(); ``` Events [#events] volumeChanged [#volumechanged] Fired whenever the system volume changes, whether triggered by your app, the user pressing hardware buttons, or system-level adjustments. The event's `detail` property contains a [`VolumeChangedEventArgs`](#volumechangedeventargs) object. **Example:** ```typescript startiapp.Media.addEventListener("volumeChanged", (event) => { const { volume } = event.detail; console.log(`Volume changed to ${volume}`); }); ``` Types [#types] VolumeChangedEventArgs [#volumechangedeventargs] Event payload attached to the `volumeChanged` event. ```typescript interface VolumeChangedEventArgs { /** The new volume level (0 to 1). */ volume: number; } ``` # Network **Access:** `startiapp.Network` Methods [#methods] currentConnectionState(): Promise [#currentconnectionstate-promiseconnectionchangeevent] Returns the current network connectivity state, including the type of network access and the active connection profile. **Returns:** `Promise` —The current connection state. **Example:** ```typescript const state = await startiapp.Network.currentConnectionState(); console.log("Network access:", state.networkAccess); console.log("Connection:", state.connectionProfiles); ``` *** startListeningForConnectionChanges(): Promise [#startlisteningforconnectionchanges-promisevoid] Starts monitoring for changes in network connectivity. Once started, the `connectionStateChanged` event fires whenever the connection state changes. **Example:** ```typescript startiapp.Network.addEventListener("connectionStateChanged", (event) => { const { networkAccess, connectionProfiles } = event.detail; console.log(`Network changed: ${networkAccess} via ${connectionProfiles}`); }); await startiapp.Network.startListeningForConnectionChanges(); ``` *** stopListeningForConnectionChanges(): Promise [#stoplisteningforconnectionchanges-promisevoid] Stops monitoring for network connectivity changes. **Example:** ```typescript await startiapp.Network.stopListeningForConnectionChanges(); ``` *** sendUdpBroadcast(port: number, message: string): Promise [#sendudpbroadcastport-number-message-string-promisevoid] Sends a UDP broadcast message on the local network. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------- | | `port` | `number` | Yes | The UDP port to broadcast on. | | `message` | `string` | Yes | The message payload to send. | **Example:** ```typescript await startiapp.Network.sendUdpBroadcast(9000, "DISCOVER"); ``` *** startListeningForUdpPackets(port: number): Promise [#startlisteningforudppacketsport-number-promisevoid] Starts listening for incoming UDP packets on the specified port. Received packets fire the `udpPacketReceived` event. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------------- | | `port` | `number` | Yes | The UDP port to listen on. | **Example:** ```typescript startiapp.Network.addEventListener("udpPacketReceived", (event) => { const { message, ip } = event.detail.detail; console.log(`Received from ${ip}: ${message}`); }); await startiapp.Network.startListeningForUdpPackets(9000); ``` *** stopListeningForUdpPackets(): Promise [#stoplisteningforudppackets-promisevoid] Stops listening for UDP packets. **Example:** ```typescript await startiapp.Network.stopListeningForUdpPackets(); ``` Events [#events] connectionStateChanged [#connectionstatechanged] Fired when the network connectivity state changes. You must call `startListeningForConnectionChanges()` first. **Event data:** `ConnectionChangeEvent` ```typescript { networkAccess: NetworkAccess; connectionProfiles: ConnectionProfile; } ``` **Example:** ```typescript startiapp.Network.addEventListener("connectionStateChanged", (event) => { if (event.detail.networkAccess === "None") { console.log("Device is offline"); } }); await startiapp.Network.startListeningForConnectionChanges(); ``` *** udpPacketReceived [#udppacketreceived] Fired when a UDP packet is received on the listened port. You must call `startListeningForUdpPackets()` first. **Event data:** `UdpPacketReceivedEvent` ```typescript { detail: { message: string; ip: string; } } ``` **Example:** ```typescript startiapp.Network.addEventListener("udpPacketReceived", (event) => { const { message, ip } = event.detail.detail; console.log(`UDP packet from ${ip}: ${message}`); }); await startiapp.Network.startListeningForUdpPackets(9000); ``` Types [#types] NetworkAccess [#networkaccess] Describes the level of network access available. ```typescript type NetworkAccess = | "ConstrainedInternet" // Limited internet (e.g. captive portal) | "Internet" // Full internet access | "Local" // Local network only, no internet | "None" // No network connectivity | "Unknown"; // State cannot be determined ``` ConnectionProfile [#connectionprofile] Describes the type of active network connection. ```typescript type ConnectionProfile = | "Bluetooth" | "Cellular" | "Ethernet" | "WiFi" | "Unknown"; ``` ConnectionChangeEvent [#connectionchangeevent] The payload for the `connectionStateChanged` event. ```typescript type ConnectionChangeEvent = { networkAccess: NetworkAccess; connectionProfiles: ConnectionProfile; }; ``` UdpPacketReceivedEvent [#udppacketreceivedevent] The payload for the `udpPacketReceived` event. ```typescript type UdpPacketReceivedEvent = { detail: { message: string; ip: string; }; }; ``` Usage Patterns [#usage-patterns] Detecting online/offline state [#detecting-onlineoffline-state] ```typescript // Check current state const state = await startiapp.Network.currentConnectionState(); const isOnline = state.networkAccess === "Internet"; // Monitor for changes startiapp.Network.addEventListener("connectionStateChanged", (event) => { const isOnline = event.detail.networkAccess === "Internet"; updateUI(isOnline); }); await startiapp.Network.startListeningForConnectionChanges(); ``` Local device discovery via UDP [#local-device-discovery-via-udp] ```typescript // Listen for responses startiapp.Network.addEventListener("udpPacketReceived", (event) => { const { message, ip } = event.detail.detail; console.log(`Device found at ${ip}: ${message}`); }); await startiapp.Network.startListeningForUdpPackets(9000); // Broadcast discovery message await startiapp.Network.sendUdpBroadcast(9000, "DISCOVER"); ``` # NFC Scanner **Access:** `startiapp.NfcScanner` Methods [#methods] isNfcSupported(): Promise [#isnfcsupported-promiseboolean] Checks whether the device hardware supports NFC. **Returns:** `Promise` —`true` if the device has NFC hardware, `false` otherwise. **Example:** ```typescript const supported = await startiapp.NfcScanner.isNfcSupported(); if (!supported) { console.log("This device does not have NFC"); } ``` *** isNfcEnabled(): Promise [#isnfcenabled-promiseboolean] Checks whether NFC is currently enabled in the device settings. A device may support NFC but have it turned off. **Returns:** `Promise` —`true` if NFC is enabled, `false` otherwise. **Example:** ```typescript const enabled = await startiapp.NfcScanner.isNfcEnabled(); if (!enabled) { console.log("Please enable NFC in your device settings"); } ``` *** startNfcScanner(): Promise [#startnfcscanner-promisevoid] Starts listening for NFC tags. Once started, the `nfcTagScanned` event fires each time a tag is read. **Example:** ```typescript // Set up the event listener first startiapp.NfcScanner.addEventListener("nfcTagScanned", (event) => { const records = event.detail; records.forEach(record => { console.log(`Type: ${record.mimeType}, Message: ${record.message}`); }); }); // Start scanning await startiapp.NfcScanner.startNfcScanner(); ``` *** stopNfcScanner(): Promise [#stopnfcscanner-promisevoid] Stops listening for NFC tags. **Example:** ```typescript await startiapp.NfcScanner.stopNfcScanner(); ``` Events [#events] nfcTagScanned [#nfctagscanned] Fired when an NFC tag is successfully read. Contains an array of NDEF records from the tag. **Event data:** `NfcTagResult[]` —An array of NDEF records found on the tag. **Example:** ```typescript startiapp.NfcScanner.addEventListener("nfcTagScanned", (event) => { const records = event.detail; records.forEach(record => { console.log("MIME type:", record.mimeType); console.log("Message:", record.message); console.log("Type format:", record.typeFormat); }); }); ``` Types [#types] NfcTagResult [#nfctagresult] Represents a single NDEF record read from an NFC tag. ```typescript type NfcTagResult = { /** The MIME type of the record (e.g. "text/plain", "application/json"). */ mimeType: string; /** The decoded message content of the record. */ message: string; /** The NFC type name format (e.g. "NfcWellKnown", "Mime", "External"). */ typeFormat: string; }; ``` Usage Patterns [#usage-patterns] Full NFC scan flow with capability checks [#full-nfc-scan-flow-with-capability-checks] ```typescript async function startNfcScan() { // Check hardware support const supported = await startiapp.NfcScanner.isNfcSupported(); if (!supported) { alert("NFC is not supported on this device"); return; } // Check if NFC is turned on const enabled = await startiapp.NfcScanner.isNfcEnabled(); if (!enabled) { alert("Please enable NFC in your device settings"); return; } // Listen for tags startiapp.NfcScanner.addEventListener("nfcTagScanned", (event) => { const records = event.detail; for (const record of records) { console.log(`Scanned: ${record.message} (${record.mimeType})`); } }); // Start scanning await startiapp.NfcScanner.startNfcScanner(); console.log("NFC scanner is active, hold a tag near the device"); } ``` Reading a URL from an NFC tag [#reading-a-url-from-an-nfc-tag] ```typescript startiapp.NfcScanner.addEventListener("nfcTagScanned", (event) => { const records = event.detail; const urlRecord = records.find(r => r.mimeType === "text/plain" || r.typeFormat === "NfcWellKnown"); if (urlRecord) { window.location.href = urlRecord.message; } }); await startiapp.NfcScanner.startNfcScanner(); ``` # Push Notifications **Access:** `startiapp.PushNotification` For a practical guide to managing topic subscriptions — see [Manage Push Topics](/sdk/how-to/manage-push-topics). Methods [#methods] getToken(): Promise [#gettoken-promisestring] Returns the FCM token for this device. The token uniquely identifies the device and is required when sending push notifications from a server. **Returns:** `Promise` —The FCM token, or an empty string if unavailable. **Example:** ```typescript const fcmToken = await startiapp.PushNotification.getToken(); console.log("FCM Token:", fcmToken); ``` *** checkAccess(): Promise [#checkaccess-promisepermissionstatus] Checks the current permission status for push notifications without prompting the user. **Returns:** `Promise` —The current permission status. **Example:** ```typescript const status = await startiapp.PushNotification.checkAccess(); if (status.granted) { console.log("Push notifications are allowed"); } ``` *** requestAccess(): Promise [#requestaccess-promiseboolean] Prompts the user to grant push notification permissions. If granted, the device is automatically subscribed to the `"all"` topic and the server is notified of the permission change. **Returns:** `Promise` —`true` if the user granted permission, `false` otherwise. **Example:** ```typescript const granted = await startiapp.PushNotification.requestAccess(); if (granted) { console.log("Notifications enabled!"); } else { console.log("User declined notifications"); } ``` You don't need to call `requestAccess()` again to keep the backend in sync when a user later enables or disables notifications in their device settings. The SDK automatically reconciles the permission with the backend on every app launch and whenever the app returns to the foreground — updating the server (and the `"all"` topic subscription) only when the status actually changed. *** setBadgeCount(count: number): void [#setbadgecountcount-number-void] Sets the app icon badge count on the device. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------------------ | | `count` | `number` | Yes | The number to display on the app badge. Set to `0` to clear. | **Example:** ```typescript // Show 3 unread notifications startiapp.PushNotification.setBadgeCount(3); // Clear the badge startiapp.PushNotification.setBadgeCount(0); ``` *** clearNotifications(options: { tags: string[] }): Promise [#clearnotificationsoptions--tags-string--promisevoid] Removes delivered notifications from the device's notification centre, by the [`tag`](/sdk/rest-api/push-notifications#request-body) they were sent with. Call it when your page has shown the user what a notification announced — they opened the app on their own and are looking at the result — so the notification does not linger as unread on the lock screen. Tags the device holds no notification for are ignored. The badge count is a separate matter and is left alone; change it with `setBadgeCount` if it should follow. Needs App Shell 4.258 or later. On older builds the call does nothing, and the notification stays until the user dismisses it. Check with `startiapp.App.supports("clearNotifications")` if your page wants to know. **Parameters:** | Parameter | Type | Required | Description | | -------------- | ---------- | -------- | -------------------------------------------------------------------------------- | | `options.tags` | `string[]` | Yes | The tags of the notifications to remove, as given in the push API's `tag` field. | **Example:** ```typescript // The render the notification announced is on screen now. await startiapp.PushNotification.clearNotifications({ tags: [`render-${renderId}`] }); // A list that shows several finished results at once await startiapp.PushNotification.clearNotifications({ tags: renders.map((render) => `render-${render.id}`), }); ``` *** getTopics(): Promise [#gettopics-promisetopic] Retrieves the list of available push notification topics for the current brand. Each returned `Topic` object includes its current subscription state. **Returns:** `Promise` —An array of `Topic` objects. **Throws:** `Error` if the server request fails. **Example:** ```typescript const topics = await startiapp.PushNotification.getTopics(); topics.forEach(topic => { console.log(`${topic.name} (${topic.topic}): ${topic.subscribed ? "subscribed" : "not subscribed"}`); }); ``` *** subscribeToTopics(topics, shouldCheckAccess?): Promise> [#subscribetotopicstopics-shouldcheckaccess-promisearray-topic-string-name-string-] Subscribes the device to one or more push notification topics. By default, requests notification permission first. **Parameters:** | Parameter | Type | Required | Description | | ------------------- | ---------- | -------- | -------------------------------------------------------- | | `topics` | `string[]` | Yes | List of topic IDs to subscribe to. | | `shouldCheckAccess` | `boolean` | No | Whether to request permission first. Defaults to `true`. | **Returns:** `Promise>` —The topics that were successfully subscribed, with their server-assigned IDs and display names. **Throws:** `Error` if permission is denied (when `shouldCheckAccess` is `true`), or if the network request fails. **Example:** ```typescript try { const subscribed = await startiapp.PushNotification.subscribeToTopics(["news", "offers"]); console.log("Subscribed to:", subscribed); } catch (error) { console.error("Subscription failed:", error.message); } ``` *** unsubscribeFromTopics(topics: string[]): Promise [#unsubscribefromtopicstopics-string-promisevoid] Unsubscribes the device from one or more push notification topics. **Parameters:** | Parameter | Type | Required | Description | | --------- | ---------- | -------- | -------------------------------------- | | `topics` | `string[]` | Yes | List of topic IDs to unsubscribe from. | **Throws:** `Error` if the network request fails. **Example:** ```typescript await startiapp.PushNotification.unsubscribeFromTopics(["news", "offers"]); ``` *** Notification history [#notification-history] The methods below let your site show a notification bell or inbox: the push notifications this device has been sent, newest first, with an unread count. The history holds notifications sent from the Manager to topics the device subscribes to, and to everyone. Messages sent to individual users, and notifications sent through the REST API, are not included. It starts the first time the app opens with an SDK version that has these methods, so it is empty on a device until the next notification arrives. When a notification is sent, the Manager can leave it out of the history. The notification history must be enabled for your app. Until it is, the history is empty. Outside the app, every method returns an empty result. getHistory(options?): Promise [#gethistoryoptions-promisenotificationhistorypage] Returns one page of the history, newest first. **Parameters:** | Parameter | Type | Required | Description | | ------------------ | ---------------- | -------- | ----------------------------------------------------------------------- | | `options.pageSize` | `number` | No | Items per page, 1–100. Defaults to `20`. | | `options.cursor` | `string \| null` | No | The `nextCursor` of the previous page. Leave it out for the first page. | **Returns:** `Promise` — the items and the cursor for the next page, which is `null` on the last page. **Throws:** `Error` if the history cannot be reached. **Example:** ```typescript let cursor = null; async function loadMore() { const page = await startiapp.PushNotification.getHistory({ cursor }); page.items.forEach(renderItem); cursor = page.nextCursor; moreButton.hidden = cursor === null; } ``` *** getUnreadCount(): Promise [#getunreadcount-promisenumber] Counts the notifications sent since the user last called `markAllAsSeen`. Use it for the badge on the bell. **Example:** ```typescript const unread = await startiapp.PushNotification.getUnreadCount(); bellBadge.textContent = unread > 0 ? String(unread) : ""; ``` *** markAllAsSeen(): Promise [#markallasseen-promisevoid] Marks every notification in the history as seen, typically when the user opens the bell. The unread count is then `0` until the next notification arrives. Stored on the device. **Example:** ```typescript bell.addEventListener("click", async () => { openInbox(); await startiapp.PushNotification.markAllAsSeen(); }); ``` *** openHistoryItem(id: string): Promise [#openhistoryitemid-string-promisevoid] Opens a notification from the history. The tap is counted in the notification's statistics in the Manager, together with taps on the push itself, once per device. If the notification was sent with a link, the app goes to it. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------------------------- | | `id` | `string` | Yes | The `id` of an item from `getHistory`. | **Example:** ```typescript function renderItem(item) { const row = document.createElement("button"); row.textContent = item.title; row.classList.toggle("unread", item.unread); row.addEventListener("click", () => startiapp.PushNotification.openHistoryItem(item.id)); inbox.append(row); } ``` Events [#events] tokenRefreshed [#tokenrefreshed] Fired when the FCM token is refreshed by the platform. If you send push notifications through the starti.app Manager, Admin API, or webhooks, this event is handled automatically — you only need to listen for it if you work directly with Firebase Cloud Messaging. **Event data:** `string` —The new FCM token. **Example:** ```typescript startiapp.PushNotification.addEventListener("tokenRefreshed", (event) => { const newToken = event.detail; console.log("Token refreshed:", newToken); // Send updated token to your server }); ``` *** notificationReceived [#notificationreceived] Fired when a push notification is received while the app is in the foreground. **Event data:** `{ title: string; body: string; tag?: string }` — the notification content, and the [`tag`](/sdk/rest-api/push-notifications#request-body) it was sent with when the sender gave it one. The event fires whether or not the system shows the notification. If the sender set [`silentInForeground`](/sdk/rest-api/push-notifications#request-body) on the message, this event is the only thing that arrives while the app is open, and presenting the message is up to your page. **Example:** ```typescript startiapp.PushNotification.addEventListener("notificationReceived", (event) => { const { title, body } = event.detail; console.log(`Notification: ${title} - ${body}`); }); ``` Types [#types] Topic [#topic] Represents a push notification topic. Returned by `getTopics()`. ```typescript class Topic { /** The unique topic identifier. */ topic: string; /** The human-readable topic name. */ name: string; /** Whether the device is currently subscribed to this topic. */ subscribed: boolean; /** Subscribes the device to this topic. */ subscribe(): void; /** Unsubscribes the device from this topic. */ unsubscribe(): void; /** Returns a plain JSON representation of the topic. */ toJSON(): { topic: string; name: string; subscribed: boolean }; } ``` **Example:** ```typescript const topics = await startiapp.PushNotification.getTopics(); const newsTopic = topics.find(t => t.topic === "news"); if (newsTopic && !newsTopic.subscribed) { newsTopic.subscribe(); } ``` *** NotificationHistoryPage [#notificationhistorypage] Returned by `getHistory()`. ```typescript interface NotificationHistoryPage { /** The notifications on this page, newest first. */ items: NotificationHistoryItem[]; /** Pass to the next getHistory call; null on the last page. */ nextCursor: string | null; } interface NotificationHistoryItem { /** Pass to openHistoryItem when the user taps the notification. */ id: string; title: string; body: string; /** Where the notification leads, when it was sent with a link. */ openToUrl: string | null; /** When it was sent. */ createdAt: Date; /** Sent after the user last called markAllAsSeen. */ unread: boolean; } ``` # QR Scanner **Access:** `startiapp.QrScanner` For a step-by-step guide to scanning QR codes — including validation and continuous scanning — see [Scan QR Codes](/sdk/how-to/scan-qr-codes). Methods [#methods] scan(options?): Promise [#scanoptions-promisestring--null] Opens the QR code scanner. Returns the scanned value when a code is read, or `null` if the scanner is already running. When a `validation` function is provided, the scanner stays open and keeps scanning until the validation function returns `true`. This is useful for rejecting invalid codes without closing the scanner. **Parameters:** | Parameter | Type | Required | Description | | --------- | --------------------------------------- | -------- | --------------------------------------------- | | options | [`QrScannerOptions`](#qrscanneroptions) | No | Scanner configuration and optional validation | **Returns:** `Promise` —The scanned QR code string, or `null` if a scan is already in progress. **Example:** ```typescript // Basic scan const result = await startiapp.QrScanner.scan(); if (result) { console.log("Scanned:", result); } ``` ```typescript // Choose front camera, hide switch button const result = await startiapp.QrScanner.scan({ cameraFacing: "front", showCameraSwitchButton: false, }); ``` ```typescript // With async validation (scanner stays open until valid code is scanned) const validResult = await startiapp.QrScanner.scan({ validation: async (scannedValue) => { // e.g. check against your server const response = await fetch(`/api/validate?code=${scannedValue}`); return response.ok; }, }); console.log("Valid code scanned:", validResult); ``` ```typescript // With synchronous validation const result = await startiapp.QrScanner.scan({ validation: (scannedValue) => { return scannedValue.startsWith("VALID_"); }, }); ``` *** stop(): Promise [#stop-promisevoid] Stops the QR code scanner and closes the camera view. **Returns:** `Promise` **Example:** ```typescript await startiapp.QrScanner.stop(); ``` *** isCameraAccessGranted(): Promise [#iscameraaccessgranted-promiseboolean] Checks whether camera access has been granted. **Returns:** `Promise` —`true` if camera access is granted. **Example:** ```typescript const granted = await startiapp.QrScanner.isCameraAccessGranted(); if (!granted) { await startiapp.QrScanner.requestCameraAccess(); } ``` *** requestCameraAccess(): Promise [#requestcameraaccess-promisevoid] Requests camera permission from the user. Shows the platform's native permission dialog. **Returns:** `Promise` **Example:** ```typescript await startiapp.QrScanner.requestCameraAccess(); ``` *** Events [#events] qrCodeScanned [#qrcodescanned] Fired each time a QR code is successfully scanned. This event fires even when using the `validation` option, allowing you to react to every scan attempt. **Event data:** `string` —The scanned QR code value. **Example:** ```typescript startiapp.QrScanner.addEventListener("qrCodeScanned", (event) => { console.log("Code scanned:", event.detail); }); ``` *** qrScannerClosed [#qrscannerclosed] Fired when the QR scanner is closed (either by the user or programmatically). **Event data:** None. **Example:** ```typescript startiapp.QrScanner.addEventListener("qrScannerClosed", () => { console.log("Scanner was closed"); }); ``` *** Types [#types] QrScannerOptions [#qrscanneroptions] ```typescript type QrScannerOptions = { /** Whether to show the camera preview while scanning. Default: true. */ showCameraPreview?: boolean; /** Which camera to use. Default: "back". */ cameraFacing?: "front" | "back"; /** Whether to show the camera switch button. Default: true. */ showCameraSwitchButton?: boolean; /** Optional validation function. Scanner stays open until it returns true. */ validation?: (scannedValue: string) => boolean | Promise; }; ``` # Share **Access:** `startiapp.Share` For a practical guide to sharing content from your app — see [Share Content](/sdk/how-to/share-content). Methods [#methods] shareFile(url: string, fileName: string): Promise [#sharefileurl-string-filename-string-promisevoid] Opens the native share sheet with a file downloaded from the given URL. The user can then share it via any installed app (email, messaging, etc.). **Parameters:** | Parameter | Type | Required | Description | | ---------- | -------- | -------- | -------------------------------------------------------- | | `url` | `string` | Yes | The URL of the file to share. | | `fileName` | `string` | Yes | The file name to use when sharing (e.g. `"report.pdf"`). | **Example:** ```typescript await startiapp.Share.shareFile( "https://example.com/files/report.pdf", "report.pdf" ); ``` *** shareText(text: string): Promise [#sharetexttext-string-promisevoid] Opens the native share sheet with the given text content. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------------- | | `text` | `string` | Yes | The text content to share. | **Example:** ```typescript await startiapp.Share.shareText("Check out this link: https://example.com"); ``` *** downloadFile(url: string, fileName: string): Promise [#downloadfileurl-string-filename-string-promisevoid] Downloads a file from the given URL and saves it to the device's default download folder. **Parameters:** | Parameter | Type | Required | Description | | ---------- | -------- | -------- | ------------------------------------------------- | | `url` | `string` | Yes | The URL of the file to download. | | `fileName` | `string` | Yes | The file name to save as (e.g. `"document.pdf"`). | **Example:** ```typescript await startiapp.Share.downloadFile( "https://example.com/files/document.pdf", "document.pdf" ); ``` # Storage **Access:** `startiapp.Storage` For an introduction to how storage works in starti.app — see [Storage and Data](/sdk/getting-started/storage-and-data). Properties [#properties] app: AppStorage [#app-appstorage] Persistent key-value storage using the native platform storage. Falls back to `localStorage` when used outside the app (e.g. on a regular website). All keys are internally namespaced with `"startiapp:"` to avoid collisions. **Important notes:** * All methods are asynchronous and return Promises. * Data is stored as strings. * Data is **not** encrypted — do not store secrets. * Storage is local to the app installation and shared across all domains. * Size limits: up to 256 KB per value, up to 50 MB total. See [AppStorage methods](#appstorage-methods) below for the full API. Methods [#methods] clearWebData(): Promise [#clearwebdata-promisevoid] Clears the HTTP cache, cookies and website storage on both platforms. The promise resolves once clearing has finished; neither platform reloads the page for you. Requires the Storage integration to be enabled for your brand. In app versions 4.1.224 and earlier, Android clears website storage only — see [Caching](/sdk/explanation/caching#clearing-web-data-debugging-only). **Example:** ```typescript await startiapp.Storage.clearWebData(); ``` AppStorage Methods [#appstorage-methods] All AppStorage methods are accessed through `startiapp.Storage.app`. getItem(key: string): Promise [#getitemkey-string-promisestring--null] Retrieves a value from storage. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------- | | `key` | `string` | Yes | The key to retrieve. | **Returns:** `Promise` —The stored value, or `null` if the key does not exist or an error occurs. **Example:** ```typescript const value = await startiapp.Storage.app.getItem("my-key"); if (value !== null) { console.log("Found:", value); } ``` *** setItem(key: string, value: string): Promise [#setitemkey-string-value-string-promisevoid] Stores a value in storage. Overwrites any existing value for the same key. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------------------------------- | | `key` | `string` | Yes | The key to store. | | `value` | `string` | Yes | The string value to store (max 256 KB). | **Example:** ```typescript await startiapp.Storage.app.setItem("user-name", "John"); // Store JSON data by serializing it await startiapp.Storage.app.setItem("settings", JSON.stringify({ theme: "dark" })); ``` *** removeItem(key: string): Promise [#removeitemkey-string-promisevoid] Removes a value from storage. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------ | | `key` | `string` | Yes | The key to remove. | **Example:** ```typescript await startiapp.Storage.app.removeItem("user-name"); ``` *** clear(): Promise [#clear-promisevoid] Clears all app storage entries. **Example:** ```typescript await startiapp.Storage.app.clear(); ``` *** isUsingNativeBridge(): boolean [#isusingnativebridge-boolean] Returns whether the native app storage bridge is being used, or whether the SDK has fallen back to `localStorage`. Useful for debugging. **Returns:** `boolean` —`true` if using native storage, `false` if using the `localStorage` fallback. **Example:** ```typescript if (startiapp.Storage.app.isUsingNativeBridge()) { console.log("Using native storage"); } else { console.log("Using localStorage fallback"); } ``` Usage Patterns [#usage-patterns] Storing and retrieving JSON data [#storing-and-retrieving-json-data] ```typescript // Store an object const settings = { theme: "dark", fontSize: 16 }; await startiapp.Storage.app.setItem("settings", JSON.stringify(settings)); // Retrieve and parse const raw = await startiapp.Storage.app.getItem("settings"); if (raw) { const parsed = JSON.parse(raw); console.log(parsed.theme); // "dark" } ``` Checking for first launch [#checking-for-first-launch] ```typescript const hasLaunched = await startiapp.Storage.app.getItem("has-launched"); if (!hasLaunched) { await startiapp.Storage.app.setItem("has-launched", "true"); // Show onboarding... } ``` # User **Access:** `startiapp.User` Properties [#properties] userId [#userid] ```typescript get userId(): string | null ``` Returns the currently registered user ID, or `null` if no user is registered. The value is read from memory first, falling back to `localStorage` under the key `startiapp-clientUserId`. **Example:** ```typescript const id = startiapp.User.userId; if (id) { console.log("Logged in as", id); } else { console.log("No user registered"); } ``` Methods [#methods] registerId(userId): Promise [#registeriduserid-promisevoid] Registers a user ID for the current device. This associates the device (identified by its installation ID and FCM push token) with your application's user identifier. Call this after the user logs in. Internally, the method also triggers an External Purchase Custom Link token fetch if the integration is available. **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------------------------------------- | | userId | `string` | Yes | Your application's unique identifier for the user. | **Returns:** `Promise` —Resolves when the registration has been confirmed by the server. **Throws:** * `Error` if no FCM token is available (push notifications must be initialized first). * `Error` if the server returns a non-OK HTTP status. **Example:** ```typescript try { await startiapp.User.registerId("user-12345"); console.log("User registered successfully"); } catch (error) { console.error("Registration failed:", error.message); } ``` *** unregisterId(): Promise [#unregisterid-promisevoid] Unregisters the current user from the device. Call this when the user logs out. The stored user ID is cleared and the server is notified so the device is no longer associated with any user. **Returns:** `Promise` —Resolves when the server confirms the unregistration. **Throws:** * `Error` if the server returns a non-OK HTTP status. **Example:** ```typescript await startiapp.User.unregisterId(); console.log("User logged out"); ``` *** deleteUser(options?): Promise [#deleteuseroptions-promisevoid] Requests deletion of the current user's account. A browser confirmation dialog is shown before the request is sent. On success, an alert informs the user that the request has been received. **Parameters:** | Parameter | Type | Required | Description | | --------- | ----------------------------------------------------------- | -------- | ------------------------------------------------------ | | options | [`RequestUserDeletionOptions`](#requestuserdeletionoptions) | No | Override the default prompt and confirmation messages. | **Returns:** `Promise` —Resolves when the deletion request has been submitted to the server. **Throws:** * `Error` if no user ID is currently registered. * `Error` if the server returns a non-OK HTTP status. **Example:** ```typescript // With default messages await startiapp.User.deleteUser(); // With custom messages await startiapp.User.deleteUser({ prompt: "Are you sure you want to delete your account?", confirmation: "Your deletion request has been received. We will follow up shortly.", }); ``` Types [#types] RequestUserDeletionOptions [#requestuserdeletionoptions] Options for customizing the `deleteUser` confirmation dialogs. ```typescript interface RequestUserDeletionOptions { /** Text shown in the confirmation dialog before sending the request. */ prompt?: string; /** Text shown in the alert after the request succeeds. */ confirmation?: string; } ``` # Overview Base URL [#base-url] ``` https://api.starti.app/v1 ``` Authentication [#authentication] All requests must include an API key in the `x-api-key` header and set `Content-Type` to `application/json`. You can obtain your API key from the starti.app manager. Each key is created for one environment — **Production** or **Test**. A Test key only talks to devices running your test app, so you can build an integration without touching real users. See [Create and manage API keys](/en/academy/manager/development-setup/api-keys). ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ https://api.starti.app/v1/... ``` Errors [#errors] | Status | Description | | ------------------ | ----------------------------------------------------------------------------------------- | | `400 Bad Request` | The request body failed validation. The response includes an `issues` array with details. | | `401 Unauthorized` | The API key is missing or invalid. | # Push Notifications Send Notification [#send-notification] ``` POST /push-notifications/send ``` Send notifications or badge updates to users, devices or topics. The request body is an array of notification objects, allowing you to send multiple notifications in a single request. Each notification targets exactly one of `userIds`, `deviceIds` or `topics` — they cannot be combined in the same object. The `userIds` correspond to the IDs registered via [`startiapp.User.registerId()`](/sdk/reference/user#registerid-userid-promisevoid) on the client side, and the `deviceIds` to the installation IDs from [`startiapp.App.deviceId()`](/sdk/reference/app#deviceid-promisestring). An API key belongs to one environment: **Production** (the app your users have installed) or **Test** (the [test version of the app](/en/academy/manager/development-setup/download-test-app) your testers install). Every call only sees that environment's devices: `userIds` and `deviceIds` resolve to devices running that app, `topics` reach that app's subscribers, and a user who only has the other app is `skipped` with `no_active_device`. Use a Test key from your staging system and a Production key from production — the key decides, so a staging configuration can never reach real users. Keys created before environments were introduced are Production keys. See [Create and manage API keys](/en/academy/manager/development-setup/api-keys). Request body [#request-body] The request body is a JSON array of notification objects. ```json [ { "userIds": ["user123", "user456"], "title": "New Message", "body": "You have received a new message", "openToUrl": "https://example.com/message/123", "badgeCount": 5 } ] ``` `silentInForeground` is for apps that present the message themselves. With it set, a user who already has the app open sees no system notification — but the app still receives the [`notificationReceived`](/sdk/reference/push-notifications#notificationreceived) event with the title and body, so your page can show it in whatever way fits. A user whose app is closed or in the background sees the notification as always. It needs no app update: the flag is handled by the app shell already in the stores. `tag` is for notifications your page can make redundant. When the user opens the app on their own and sees what the notification announced — an order that shipped, a result that is ready — the notification would otherwise stay on the lock screen as unread. Send it with a `tag` (say `order-8812`), and call [`clearNotifications`](/sdk/reference/push-notifications#clearnotificationsoptions--tags-string--promisevoid) with that tag once the page has shown the thing. Two rules come with the name: a later send with the **same tag** to the same device *replaces* the earlier notification rather than adding a second one, and a device that is offline receives only the newest of them. So use a tag per thing announced, not per kind of message, unless replacing is what you want. The tag also arrives in the `notificationReceived` event. Removing needs App Shell 4.258 or later; on older builds the notification stays until the user dismisses it. `sound` picks one of the notification sounds uploaded under **Push notifications → Notification sounds** in Manager. A sound is part of the app itself, so a newly uploaded one plays once you have released a new version of the app. Until then — and on phones still running an older version — the notification plays the phone's own sound instead; it is never held back. Leave `sound` out and the notification plays the default sound you chose in Manager, which is the phone's own sound until you pick another. Exactly one of `userIds`, `deviceIds` or `topics` must be provided. To send a visible notification, include both `title` and `body`. To update only the badge count, omit `title` and `body` and provide `badgeCount`. Examples [#examples] ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "userIds": ["user123"], "title": "New Message", "body": "You have received a new message", "openToUrl": "https://example.com/message/123" }, { "userIds": ["user456"], "badgeCount": 5 } ]' \ https://api.starti.app/v1/push-notifications/send ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "deviceIds": ["00000000-0000-0000-0000-000000000000"], "title": "Signed in on a new device", "body": "Tap to review your recent activity" } ]' \ https://api.starti.app/v1/push-notifications/send ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "topics": ["general", "announcements"], "title": "System Update", "body": "The system will be updated tonight", "openToUrl": "https://example.com/updates" } ]' \ https://api.starti.app/v1/push-notifications/send ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "userIds": ["user123"], "badgeCount": 5 } ]' \ https://api.starti.app/v1/push-notifications/send ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "userIds": ["user123"], "title": "New Message", "body": "You have received a new message", "silentInForeground": true } ]' \ https://api.starti.app/v1/push-notifications/send ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "userIds": ["user123"], "title": "Your visualisation is ready", "body": "Open the app to see your car with the new wheels", "openToUrl": "https://example.com/renders/8f3a2c", "tag": "render-8f3a2c" } ]' \ https://api.starti.app/v1/push-notifications/send ``` Then, on the page that shows the render: ```typescript startiapp.PushNotification.clearNotifications({ tags: ["render-8f3a2c"] }); ``` ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '[ { "topics": ["offers"], "title": "Flash sale", "body": "Everything 30% off for the next hour", "sound": "cash_register" } ]' \ https://api.starti.app/v1/push-notifications/send ``` Responses [#responses] Every response carries an **`x-request-id`** header — the empty 200 of a fully delivered batch included. It identifies the call, so you have something to record alongside your own logs and something concrete to quote in a support request. If your brand has the notification log switched on in Manager, that same id is what a call is listed under there: the body you sent, the recipients it resolved to, and the outcome for each of them. Nothing else about the request or the response changes — the header is an addition, and every field documented below is unaffected. Every target in the request gets its own outcome in the `results` array, each echoing what you addressed it with — `userId`, `deviceId` or `topics`: * **`delivered`** — handed to the push service. `deviceCount` says how many devices it went to. * **`skipped`** — nothing to deliver to. `reason` says why; currently always `no_active_device`. * **`failed`** — delivery was attempted and failed. `message` carries the underlying error. The status code follows the worst outcome in the batch: **207** if any target failed, otherwise **200**. It tells you whether anything needs acting on — not whether every recipient was reached — so skipped targets never make a request 207. `no_active_device` means the user has no device registered, or every device behind them has lost its push token — typically because the app was uninstalled or notifications were turned off — or their only device runs the other environment's app (a Test key cannot reach the production app, and vice versa). Retrying will not help, so treat it as a signal to stop sending to that user rather than as a failure. They become reachable again the moment the app registers a new token. When we know when we last heard from the device, `unreachableSince` tells you how long they have been gone. An empty response indicates all notifications were sent successfully. A batch where some recipients were skipped is still a 200 — nothing failed, so there is nothing to act on. The body then describes every target, delivered ones included: ```json { "message": "Notifications sent with warnings", "warnings": [ "No active device for client user id: user789" ], "summary": { "requested": 2, "delivered": 1, "skipped": 1, "failed": 0 }, "results": [ { "target": { "type": "userId", "value": "user123" }, "status": "delivered", "deviceCount": 2 }, { "target": { "type": "userId", "value": "user789" }, "status": "skipped", "reason": "no_active_device", "message": "No active device for client user id: user789", "unreachableSince": "2026-04-16T17:41:09.183Z" } ] } ``` Returned when at least one target failed. The body has the same shape as the 200 response: ```json { "message": "1/2 push notifications failed to send", "warnings": [ "1/1 push notifications failed (messaging/third-party-auth-error: APNs auth error)" ], "summary": { "requested": 2, "delivered": 1, "skipped": 0, "failed": 1 }, "results": [ { "target": { "type": "userId", "value": "user123" }, "status": "delivered", "deviceCount": 1 }, { "target": { "type": "deviceId", "value": "00000000-0000-0000-0000-000000000000" }, "status": "failed", "reason": "delivery_failed", "message": "1/1 push notifications failed (messaging/third-party-auth-error: APNs auth error)" } ] } ``` Returned when the request body fails validation. ```json { "message": "Invalid request body", "issues": [ [ { "code": "invalid_type", "expected": "number", "received": "undefined", "path": [0, "badgeCount"], "message": "Required" } ] ] } ``` A `sound` that is not one of your notification sounds is rejected the same way, and the message lists the names you can use: ```json [ { "message": "Unknown sound \"cash_registr\". Available sounds: default, cash_register, doorbell", "issue": "0.sound", "path": [0, "sound"] } ] ``` Returned when the API key is missing or invalid. List Recipients [#list-recipients] ``` GET /push-notifications/recipients ``` Returns the user IDs that can receive a push notification right now. Use it when you keep your own list of users and want to send only to the ones that will actually get the message, instead of paying a `skipped` result for every user who has not opted in. A user is included when at least one of their devices: * is signed in with that user ID via [`startiapp.User.registerId()`](/sdk/reference/user#registerid-userid-promisevoid), * still holds a push token — the app is installed and has registered with the push service, * has recorded that the user granted the notification permission, and * runs the app that matches the key's environment (production or test). The list is maintained as devices change, so it reflects what is true right now, not a snapshot from earlier in the day. Fetch the list right before each send rather than caching it — users opt in and uninstall all the time. Then keep each [`POST /push-notifications/send`](#send-notification) to 500 `userIds` or fewer: a campaign to every recipient is one `GET`, split into batches of 500, each sent as its own request. Example [#example] ```bash curl \ -H "x-api-key: YOUR_API_KEY" \ https://api.starti.app/v1/push-notifications/recipients ``` Responses [#responses-1] ```json { "userIds": ["user123", "user456", "user789"], "count": 3, "asOf": "2026-09-17T09:41:09.183Z" } ``` `userIds` are returned as strings exactly as they were registered, whatever type your site passed to `registerId()`. `asOf` is when the list was read. Returned when the API key is missing or invalid. Returned for demo tokens, which can only send to topics. # Apple App Store Apple App Store [#apple-app-store] This guide walks you through setting up your App Store Connect API key and publishing your app to the Apple App Store. Prerequisites [#prerequisites] * An [Apple Developer account](https://developer.apple.com/) (requires enrollment in the Apple Developer Program) * Access to [App Store Connect](https://appstoreconnect.apple.com/) Create an App Store Connect API key [#create-an-app-store-connect-api-key] The starti.app team needs an API key to manage builds and submissions on your behalf. Open Users and Access [#open-users-and-access] Go to [App Store Connect](https://appstoreconnect.apple.com/) and click **Users and Access** in the top navigation bar. Navigate to Integrations [#navigate-to-integrations] Click the **Integrations** tab, then select **App Store Connect API** in the left sidebar under **Keys**. Users and Access page showing the Integrations tab Request access [#request-access] If this is your first time, click **Request Access** and accept the terms. Generate a new key [#generate-a-new-key] Click **Generate API Key** and fill in the details: * **Name:** `starti.app` (so you remember who has this key) * **Access:** Select **Admin** to grant the required privileges Click **Generate**. Generate API Key dialog with name set to starti.app and access set to Admin Download and send the key [#download-and-send-the-key] After generating, you will see the key listed with an **Issuer ID** and **Key ID**. The API key can only be downloaded **once**. Download it immediately and keep it safe. Prepare an email to **[developer@starti.app](mailto:developer@starti.app)** with: 1. The **Issuer ID** (shown above the keys list) 2. The **Key ID** (shown in the key row) 3. The downloaded **API Key file** (.p8) as an attachment 4. Your **Team ID** (found under the profile icon > Edit Profile) App Store Connect API page showing Issuer ID, Key ID, and Download button Find your Team ID [#find-your-team-id] Click the profile icon in the upper-right corner, select **Edit Profile**, and copy the **Team ID**. Edit Profile page showing Team ID Send the email [#send-the-email] Send the email with all four pieces of information to **[developer@starti.app](mailto:developer@starti.app)**. What happens next [#what-happens-next] The starti.app team will: 1. Configure your app's build pipeline 2. Upload your first build to App Store Connect 3. Help you set up your App Store listing (screenshots, description, keywords) 4. Submit the app for review Updating your app [#updating-your-app] After the initial setup, new versions go through the same pipeline: 1. You make changes to your web app (no app update needed for web-only changes) 2. For native changes, the starti.app team builds and uploads a new version 3. The new version goes through App Review (typically 24-48 hours) Web content changes do not require a new App Store submission. Only changes to native configuration or permissions require a new build. # Google Play Store Google Play Store [#google-play-store] This guide walks you through creating a Google Play service account and publishing your app to the Google Play Store. Prerequisites [#prerequisites] * A [Google Play Developer account](https://play.google.com/console/) (requires a one-time registration fee) * Access to [Google Cloud Console](https://console.cloud.google.com/) Create a Google Play service account [#create-a-google-play-service-account] The starti.app team needs a service account to manage builds and submissions on your behalf. Open API access [#open-api-access] Go to [Google Play Console](https://play.google.com/console/), navigate to **Setup** > **API access**. Google Play Console showing Setup and API access in sidebar Create a Google Cloud project [#create-a-google-cloud-project] Select **Create a new Google Cloud project** and click **Save**. Create a new Google Cloud project option selected Open Google Cloud Platform [#open-google-cloud-platform] Click **View in Google Cloud Platform** to configure the project. Create a service account [#create-a-service-account] In the Google Cloud Console: 1. Go to **IAM & Admin** > **Service Accounts** 2. Click **CREATE SERVICE ACCOUNT** Service accounts page with Create Service Account button Configure the service account [#configure-the-service-account] Fill in the details: * **Name:** `Google Play` (or similar) * **ID:** `google-play` * **Description:** `Used for the Google Play integration` Click **CREATE AND CONTINUE**. Service account creation form with name, ID, and description fields Set permissions [#set-permissions] Under **Role**, select **Service Accounts** > **Service Account User**. Click **Continue**, then **Done**. Role selection showing Service Accounts and Service Account User Create a JSON key [#create-a-json-key] 1. Find the new service account in the list 2. Click the three-dot menu and select **Manage Keys** 3. Click **ADD KEY** > **Create new key** 4. Select **JSON** and click **CREATE** A JSON file will download automatically. This file needs to be sent to the starti.app team. Manage Keys page with ADD KEY dropdown showing Create new key Grant Play Console permissions [#grant-play-console-permissions] Go back to [Google Play Console](https://play.google.com/console/) > **Setup** > **API access**. 1. Scroll down to find the new service account 2. Click **Manage Play Console permissions** 3. Click **Invite user** and then **Send invitation** Play Console API access page showing Manage Play Console permissions link Send the key file [#send-the-key-file] Email the downloaded JSON key file to **[developer@starti.app](mailto:developer@starti.app)**. What happens next [#what-happens-next] The starti.app team will: 1. Configure your app's build pipeline 2. Upload your first build to Google Play Console 3. Help you set up your store listing 4. Submit the app for review Updating your app [#updating-your-app] After the initial setup: 1. Web-only changes take effect immediately (no store update needed) 2. For native changes, the starti.app team builds and uploads a new version 3. Google Play review is typically faster than Apple (hours, not days) Web content changes do not require a new Play Store submission. Only changes to native configuration or permissions require a new build. # Introduction import { Cards, Card } from 'fumadocs-ui/components/card'; import { Callout } from 'fumadocs-ui/components/callout'; Welcome to starti.app Academy [#welcome-to-startiapp-academy] starti.app Academy is where you learn to use starti.app Manager from the ground up — and build the knowledge you need to run your app professionally. Whether you're new to the platform or just want to sharpen your skills on specific features, you'll find step-by-step guides here that take you all the way through. *** What will you learn? [#what-will-you-learn] The Academy is structured as a course you can follow from start to finish — or jump directly to what you need. *** What will you be able to do when you're done? [#what-will-you-be-able-to-do-when-youre-done] When you've completed starti.app Academy, you'll be able to: * **Set up your app correctly** from day one — with the right settings, users, and testing options * **Send push notifications** targeted at the right users at the right time * **Create automatic welcome flows** for a great onboarding experience for new users * **Track your app's performance** via the dashboard and understand what the numbers mean * **Handle technical configurations** such as website integrations and API keys You don't need to go through everything at once. Start with what's most relevant for you right now — and come back when you're ready for more. *** Who is Academy for? [#who-is-academy-for] Academy is primarily for those working in **starti.app Manager** — the platform you use to manage and communicate with your app users. You don't need a technical background to follow the guides. If you need the technical documentation for starti.app — such as SDK, API, or app configuration — you'll find it in [starti.app Docs](/sdk). # Turn on auto-renewal for your Apple Developer membership Turn on auto-renewal for your Apple Developer membership [#turn-on-auto-renewal-for-your-apple-developer-membership] If your app is available in Apple's App Store, it's a good idea to have auto-renewal ("Auto-renew") turned on for your Apple Developer membership. Otherwise you risk the membership expiring. In the worst case, this can result in your app being removed from the App Store until the membership is renewed again. How to turn on auto-renewal [#how-to-turn-on-auto-renewal] Log in to your Apple Developer account [#log-in-to-your-apple-developer-account] Go to [your Apple Developer account](https://developer.apple.com/account) and log in. Make sure "Auto-renew" is turned on [#make-sure-auto-renew-is-turned-on] Once you're logged in, go to **Membership details** and make sure **Auto-renew** is turned on. What it costs [#what-it-costs] An Apple Developer membership costs $99 per year. The price is set by Apple and may change over time. We therefore can't guarantee that the amount above is always fully up to date — you'll always find the current price on your Apple Developer account. Not sure where to find the setting? We're of course happy to help. See also [#see-also] Keep your app texts and contact details up to date for the next release # View app performance in Dashboard How to use the Dashboard [#how-to-use-the-dashboard] What is the Dashboard? [#what-is-the-dashboard] The Dashboard is the front page of starti.app Manager and gives you a complete overview of how your app is performing. Here you can follow the development in downloads and active users over time — and see whether your push notifications are making an impact. You don't need to do anything other than [open the page](https://manager.starti.app) to see the data. All three graphs update automatically when you log in. The three graphs [#the-three-graphs] Active users [#active-users] This graph shows how many users are active in your app over the selected time period. You can see two curves: * **Total number of users** — the total number of users who have opened the app in the period * **New users** — the number of users who have opened the app for the first time in the period Downloads [#downloads] This graph shows how many new downloads the app has received per day or per month. A new download corresponds to a new user opening the app for the first time. Use it to assess whether the app is growing, and to see whether specific activities — such as campaigns, events, or newsletters — have led to an increase in downloads. Push effect [#push-effect] The Push effect graph is only available when viewing data per day. It combines two types of information in the same graph: * **The blue curve** shows the number of active users per day * **The dotted lines** mark the days when you have sent a push notification to categories or to all users of the app The graph gives you a quick visual answer to whether your push notifications are leading to increased activity in the app. Select time period [#select-time-period] In the top right of the page, you can switch between two views: * **Day** — shows daily figures for the last 365 days. The Push effect graph is available in this view. * **Month** — shows monthly figures for the last 12 months. The Push effect graph is not shown here. Click **Day** or **Month** to switch between the two views. Your selection is remembered the next time you open the [Dashboard](https://manager.starti.app). Update data [#update-data] Click the **refresh icon** in the top right to manually fetch the latest data into the graphs. *** Keep an eye on the Push effect graph on the days you send push notifications. A marked increase in active users on the same day or the day after is a good sign that your push notification is working. See also [#see-also] Learn to send push notifications and measure the effect in the Dashboard Share a direct link to your app that works on all platforms # Get more downloads with Flowlink How to use Flowlink [#how-to-use-flowlink] What is Flowlink? [#what-is-flowlink] Flowlink is your app's universal sharing link. It's a single link that automatically sends the user to the right place — regardless of whether they open it on an iPhone, an Android phone, or a computer: * **iPhone** — the user is sent directly to your app in the App Store * **Android** — the user is sent directly to your app in Google Play * **Computer (PC/Mac)** — the user sees a QR code they can scan with their phone to be sent to the right app store Your Flowlink looks like this: ``` https://link.starti.app/[your-brand] ``` Where do you find your Flowlink? [#where-do-you-find-your-flowlink] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Go to Flowlink [#go-to-flowlink] Go to **[Flowlink](https://manager.starti.app/flowlink)** in the left-hand menu bar under **Setup**. Find your link and QR code [#find-your-link-and-qr-code] On the page you'll find two things: * **Your Flowlink** — copy the link by clicking the copy icon, or open it directly in the browser by clicking the arrow icon * **The QR code** — see the section below to learn how to download and use it How to download the QR code [#how-to-download-the-qr-code] The QR code is automatically generated from your Flowlink and is ready to use immediately. It can be used on everything from roll-up banners and posters to business cards and other printed materials. To download the QR code: 1. Go to the [Flowlink page](https://manager.starti.app/flowlink) in starti.app Manager 2. Click **"Download at full size"** below the QR code 3. The QR code opens in a new tab — right-click and choose **"Save as..."** to save it to your computer The QR code is saved as an SVG file, which can be scaled to any size without losing quality — perfect for print. The full-size version carries your app icon at full resolution, so it stays sharp on a roll-up banner too. Putting the QR code on your own website? [#putting-the-qr-code-on-your-own-website] Then don't download it. Link to the screen version instead — it is far lighter, so it adds almost nothing to your page: ```html QR code for the app ``` Replace `your-brand` with whatever follows `link.starti.app/` in your own Flowlink. You can also copy the address straight from below the QR code on the Flowlink page. The advantage of linking rather than uploading a file: the QR code is always the current one if your Flowlink changes. Make sure to test the QR code before sending it to print. Scan it with your own phone and check that you land on the right page. *** When is Flowlink useful? [#when-is-flowlink-useful] Flowlink is ideal when you want to share a link to the app but don't know whether the recipient uses an iPhone or Android. Instead of having two separate links — one for the App Store and one for Google Play — you can always use the single Flowlink. **Examples of use:** * Link in newsletters and emails encouraging users to download the app * QR code on roll-up banners, posters, or printed materials at events and trade shows * Link on your website * QR code on products, packaging, or in stores * Link in social media posts See also [#see-also] Bring users back to the app with push notifications # See and manage your app users How to use App users [#how-to-use-app-users] What are App users? [#what-are-app-users] **App users** shows the users your app has registered itself. When a user logs in or identifies themselves in the app, they get a user ID and then appear here — together with the devices they use the app on. On this page you can: * See **who is logged in**, and on which devices * See whether each device has **allowed push notifications** * See the device's **language** and **time zone** * **Send a push notification to one specific user** * **Delete** a device or an entire user App users is an optional feature. If you can't see the page in the menu (under **Tools**), the feature isn't enabled for your app yet — get in touch and we'll turn it on for you. How to find a user [#how-to-find-a-user] Open [starti.app Manager](https://manager.starti.app) and log in with Microsoft, Google or Apple. Then go to [App users](https://manager.starti.app/registered-users) in the menu bar on the left under **Tools**. At the top of the page there's a search field. * **Leave the field empty** to see the most recently active users. * **Type a user ID** to find a specific user. The search matches the start of the ID, and it is case-sensitive. If there are many users, the page only shows the most recently active ones at a time. Search to find the exact user you're looking for. What a user card shows [#what-a-user-card-shows] Each user appears as a card. At the top you'll see: * The **user ID** — the identification your app gave the user * **Last active** — how long ago the user last used the app * The **Send push** button — send a notification to this user only (see below) * The **Delete user** button — remove the user and all of their devices Below the card you'll see the user's **devices** — one line per phone or tablet the user has the app on. What each device line shows [#what-each-device-line-shows] * **The phone icon** — click it to copy the device's installation ID (handy if our support team asks for it) * **The console log icon** — opens [the console log](/en/academy/manager/development-setup/console-log) for that exact device, so you can follow what the app writes out for that user. It's a tool for developers, used for troubleshooting. Only shown if Console log is enabled for your app * **Push status** — one of three colour-coded labels: * **Push allowed** (green) — the device receives notifications * **Push not allowed** (red) — the user declined notifications * **Not yet asked** (amber) — the app hasn't asked for permission yet * **Categories** — the push categories the device is subscribed to (if it says **All**, the device receives every message) * **Language** — the device's language * **Time zone** — the device's time zone * **Last active** — when the app last ran on the device Send a push to one user [#send-a-push-to-one-user] Click **Send push** on a user card. You'll be taken to the **Send push notification** page with that user already selected as the recipient. Write your message and send — the notification then reaches only that one user. This is useful when you need to help a single customer, test a message on yourself, or send something personal to a specific user. Delete a device or a user [#delete-a-device-or-a-user] Next to each device there's a **trash icon**, and each user card has a **Delete user** button. You'll always be asked to confirm before anything is deleted. When you delete a device or a user, the registration disappears. If the app is still in use on the device, it will register itself again the next time the app is opened. Deleting is therefore mainly for tidying up old or unused registrations. *** See also [#see-also] Send a push notification to all users or to specific categories Track downloads and active users over time # Add Smart Banner to your website Add Smart Banner to your website [#add-smart-banner-to-your-website] What is a Smart Banner? [#what-is-a-smart-banner] A Smart Banner is a small strip that appears at the top of your website when someone visits it on a mobile phone. The banner shows your app icon, app name, and a button — for example "Open" — that takes the user directly to your app or to the App Store/Google Play if they haven't installed it yet. It's a simple and effective way to increase downloads, because you reach people who are already visiting your website. *** How to set up the Smart Banner [#how-to-set-up-the-smart-banner] Enable the banner [#enable-the-banner] Go to **[Smart Banner](https://manager.starti.app/smart-banner)** in the left-hand menu bar. Turn on **Show smart banner** using the toggle at the top of the page. The rest of the settings become available once you enable the banner. Choose a banner style [#choose-a-banner-style] Under **General** you can choose between two styles: The classic banner style with a close button on the left side and rounded corners. Users can close the banner if they don't want to download the app. A compact version without a close button. Works well on websites with a minimalist design. Add details (optional) [#add-details-optional] In the **Details** field you can write a short line of text shown below the app name in the banner — for example "Free · Open directly in the app" or a brief description of the app. This field is optional. Leave it empty if you only want to show the app name and button. Customise the appearance (optional) [#customise-the-appearance-optional] Under **Appearance** you can adjust the banner to match your brand's colours: * **Background colour** — the colour behind the entire banner * **Text colour** — the colour of the app name and details text * **Button colour** — the background colour of the button * **Button text colour** — the colour of the text inside the button * **Button text** — the label on the button, e.g. "Open" or "Download" Click a colour field to open the colour picker. You can choose a colour visually or type a colour code directly (e.g. `#007aff`). Restrict to specific domains (optional) [#restrict-to-specific-domains-optional] Under **Domain targeting** you can choose to only show the banner on specific pages of your website. Type a domain — for example `example.com` or `shop.example.com` — and press Enter or click **+** to add it. You can add multiple domains. If you leave this field empty, the banner will appear on all pages that use your app integration. Publish to web [#publish-to-web] Click the **Publish to web** button at the top of the page to make the banner visible to your visitors. Please note that it may take 10-15 minutes before your Smart Banner becomes visible on the website. Your changes are not visible on the website until you click **Publish to web**. Remember this step before you close the page. The banner shows your app icon from `cdn.starti.app`. If your website has a Content Security Policy, it must allow images from that address (`img-src https://cdn.starti.app`), or the icon will not appear. If you change your app icon, click **Publish to web** again to update the banner. See [Content Security Policy](/sdk/explanation/cdn-and-branding#content-security-policy-csp). *** See also [#see-also] Share a single link to your app that works on iPhone, Android, and PC Track how your app is performing with key metrics and statistics # Create and manage API keys Create and manage API keys [#create-and-manage-api-keys] What are API keys? [#what-are-api-keys] API keys allow you to send push notifications programmatically — for example from your own system, a webshop, or an automation tool — without having to manually log in to starti.app Manager. Each API key is linked to your brand and has permission to send push notifications via starti.app's API. An API key is only shown once, when it is created. Make sure to save it somewhere safe immediately — you cannot retrieve it again afterwards. Create a new API key [#create-a-new-api-key] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open API keys [#open-api-keys] Go to **[API keys](https://manager.starti.app/api-keys)** in the left-hand menu bar. Start creating [#start-creating] Click **Create new key** in the top right of the page. Give the key a name [#give-the-key-a-name] Enter a descriptive name for the key in the **Name** field — e.g. "Webshop integration" or "Newsletter automation". The name helps you keep track of which systems are using which key. Choose the environment [#choose-the-environment] Pick **Production** or **Test**. A Production key sends to the app your users have installed. A Test key only reaches the [test version of the app](/en/academy/manager/development-setup/download-test-app), so use it from your staging system while you build the integration — a test send can never reach a real user. The choice follows the Prod/Test selector in the header and cannot be changed afterwards; create a second key instead. Keys created before this option existed are Production keys. Save the key safely [#save-the-key-safely] Click **Create key**. Your new API key is now shown in a box on the screen. Click the copy icon and save the key somewhere safe — for example in your password manager or as an environment variable in your system. Then click **I have saved the key** to close the dialog. The key is only shown this one time. If you haven't saved it, you'll need to delete the key and create a new one. *** Manage your API keys [#manage-your-api-keys] Under **Active API keys**, you can see an overview of all keys created for your brand. For each key, the following is shown: * **Name** — the name you gave the key when creating it * **Permissions** — what the key has access to (currently `PushNotifications/Send`) * **Environment** — Production or Test; which app the key's sends reach * **Created** — when the key was created * **Created by** — which user created the key * **Last used** — when the key was last used to call the API * **Usage** — how many times the key has been used Delete an API key [#delete-an-api-key] Click the trash icon next to the key you want to delete, and confirm the deletion in the dialog that appears. Deleting an API key cannot be undone. All systems using the key will immediately lose access and stop working. Make sure to update your systems before deleting a key. *** Create separate keys for each of your systems or integrations. That way, you can easily revoke access for one system without affecting the others. See also [#see-also] Learn to send push notifications manually from starti.app Manager Add the HTML snippet to your website to integrate the app # App icon App icon [#app-icon] What is this page? [#what-is-this-page] On this page you upload your **app icon** — the icon users tap on their phone's home screen. It's the first thing users see of your app. Changes to your app icon require starti.app to publish a new version of your app before your users will see them. Your changes are saved right away, but the updated app is published by starti.app afterwards. How to set up the app icon [#how-to-set-up-the-app-icon] Upload your icon [#upload-your-icon] Click the upload area and choose your icon file. Only **PNG** and **SVG** files are allowed, and a size of **1024×1024** works best. Your icon is automatically adjusted to the correct size and squared when you upload it, so you don't need to resize it yourself first. Choose a background color [#choose-a-background-color] Under **Background color** you pick the color shown around your icon on the phone. Choose a color that fits your app's design. Adjust the size for iOS and Android [#adjust-the-size-for-ios-and-android] You'll see a preview of how the icon looks on both **iOS** (rounded square) and **Android** (circle). Under each preview there is a **scaling** slider you can drag to make the icon appear a little larger or smaller, so it looks right on both platforms. Your [splash screen](https://manager.starti.app/splash-screen) — the image users see while the app starts up — is set up on its own page. By default it reuses your app icon. Read how to set up the Splash screen [here](/en/academy/manager/development-setup/splash-screen). Your changes are saved automatically as you make them. You can follow the save status in the top bar of starti.app Manager. See also [#see-also] Set up what users see while the app starts up — icon, animation, or fullscreen image Control how your app behaves when it loads — gestures, status bar, safe areas, and the loading spinner # App Links – make links open in the app App Links (Universal Links) [#app-links-universal-links] What are App Links? [#what-are-app-links] App Links (also called Universal Links) let an ordinary HTTPS link from your website — for example a link in a push notification — open **directly in the app** instead of the browser. If the app isn't installed, the link simply opens the website as normal instead. On this page you do two things: 1. Choose which **domains and paths** should open the app 2. **Generate and host some files** on your domain — and test that it works App Links is an optional feature. If you can't see the page in the menu (under **Development**), it isn't enabled for your app yet — get in touch and we'll turn it on. 1. Choose domains and paths [#1-choose-domains-and-paths] Under **Domains** you add the links that should open the app: * **Domain** — e.g. `example.com` * **Path (optional)** — e.g. `/notifications`, so only links to that path and everything under it (e.g. `/notifications?id=42` and `/notifications/42`) open the app Always set a path if you can. If you leave the path field empty, the app tries to capture **every** link on the domain — including normal web pages and login/payment flows. That can interrupt regular browsing and break things like signing in with Google or Apple. Always limit it to the path you need. If you add a rule that is already covered by a broader rule on the same domain, Manager marks it as **redundant** so you can remove it again. 2. Host the files on your domain, and test [#2-host-the-files-on-your-domain-and-test] Under **Files for .well-known/**, Manager generates the files the phone uses to verify the connection between website and app: * **apple-app-site-association** — for iOS * **assetlinks.json** — for Android This section is split into two steps. Step 1: Insert the files [#step-1-insert-the-files] Copy or download each file and place it in the `.well-known/` folder on your domain, so it can be fetched directly over HTTPS. The files must be served directly, with no redirect, and with Content-Type `application/json`. Apple and Android won't accept a file that sits behind a redirect. Step 2: Test the setup [#step-2-test-the-setup] Click **Test all domains** (or **Test** next to a single domain). Manager fetches the files you've hosted and checks, among other things, that: * The file can be fetched over HTTPS and returns HTTP 200 * There is no redirect * The Content-Type is `application/json` and the content is valid JSON * The file contains the app's identifiers, and the path matches your setup If all domains pass the test, you're ready — the links will open in the app the next time the app is built. *** See also [#see-also] Prepare your website to work together with the app Send notifications with links that open directly in the app # View the console log from a device View the console log from a device [#view-the-console-log-from-a-device] What is the console log? [#what-is-the-console-log] **Console log** shows you, line by line, what your app writes out while it runs on one specific device. It is the same output a developer would see in a browser's developer tools — `console.log`, warnings and errors — but from the app on a real phone. It is a troubleshooting tool. Use it when something doesn't work as expected and you need to see what actually happens, rather than guess: * A button doesn't do anything when a tester taps it * A page loads on a computer but not in the app * A login fails for one user but works for everyone else * You want to confirm that a change you just made really reached the device Console log is an optional feature. If you can't see the page in the menu (under **Tools**), the feature isn't enabled for your app yet — get in touch and we'll turn it on for you. Before you begin [#before-you-begin] You need a device to watch. The list of devices is the same one you find under [Test devices](/en/academy/manager/getting-started/test-devices) — so add the phone as a test device first if it isn't there already. If you know the user it goes wrong for instead, you can jump straight to their device from the **App users** tool — see [the shortcut further down](#shortcut-view-the-log-for-a-specific-app-user). You can only watch **one device at a time**, and only while the app is open on it. The console log is a live view, not a recorded history — so have the device in front of you, or ask the person holding it to reproduce the problem while you watch. Step-by-step guide to reading the console log [#step-by-step-guide-to-reading-the-console-log] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Console log [#open-console-log] Go to **[Console log](https://manager.starti.app/webapp-console)** in the menu, under **Tools**. Choose a device [#choose-a-device] Click **Select device** and pick the phone you want to follow. You'll see the device name, a short description, and a badge showing which environment it is on. The connection starts by itself as soon as you pick a device — you don't need to click anything else. The status next to the picker changes to **Connecting…** and then to **Live**. When you connect, the last couple of minutes of output are pulled in as well, so you can see what led up to the moment you started watching. Use the app on the device [#use-the-app-on-the-device] Do the thing that goes wrong — tap the button, open the page, try the login. New lines appear at the bottom as they happen, and the view follows along. Read the lines [#read-the-lines] Every line has four parts: * **Time** — down to the millisecond, so you can see exactly what followed what * **Level** — how serious it is: `info`, `warn`, `error`, `debug` or `trace`. Errors are red and warnings are amber, so they're easy to spot in a long log * **Origin** — which part of the app wrote the line * **Message** — the text itself Red `error` lines are usually where you should look first. *** Shortcut: view the log for a specific app user [#shortcut-view-the-log-for-a-specific-app-user] If you want to troubleshoot for one particular **user** rather than one particular device, you can come at it from the other direction and start in the **App users** tool. Open App users [#open-app-users] Go to **[App users](https://manager.starti.app/registered-users)** in the menu, under **Tools**. Find the user [#find-the-user] Search for them in the **Search by user ID…** field. The search matches the start of the user ID and is case-sensitive — so you need to know the user's ID. Leave the search field empty to see the most recently active users. That's often the quickest route if the user has just been in the app. Click the console log icon next to the device [#click-the-console-log-icon-next-to-the-device] Under the user you'll find the devices they use the app on. Click the console log icon next to the exact device you want to test on. The icon looks like a small terminal, and hovering over it says **View console log for this device**. The log opens for that user [#the-log-opens-for-that-user] The console log opens with that device already selected, and the connection starts by itself. From there the page works exactly as described above — same controls, same filter, same status. The device doesn't have to be a test device when you come this way. If it isn't on the list of test devices, it appears in the picker as **Selected device** followed by the first characters of the device's ID. If you can't see the console log icon next to the devices, Console log isn't enabled for your app — get in touch and we'll turn it on for you. *** The controls [#the-controls] Along the top of the page you'll find everything you need while the log is running. | Control | What it does | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Stop** | Ends the connection. The lines you already have stay on screen | | **Reconnect** | Connects again after a stop or an error, and pulls in the last few minutes | | **Pause** | Freezes the view where it is so you can read a line without it scrolling away. Lines keep arriving in the background — click **Resume** to jump back to the bottom | | **Filter…** | Type a word to show only the lines that contain it. It searches the message, the level, and the origin — so typing `error` gives you just the errors | | **Show starti.app log** | Off by default. Turn it on to also see what starti.app itself writes, on top of your own app's output | | **Copy** | Copies what you can currently see — including your filter — so you can paste it into an email or a support ticket | | **Clear** | Empties the view. It doesn't stop the connection; new lines keep coming | Below the log you can see how many lines you're looking at, for example "Showing 34 of 512 lines" — the first number is after your filter, the second is everything received. The page keeps the most recent 2,000 lines. On a busy app, the oldest lines drop off the top — so if you need to keep something, use **Copy** while it's still on screen. What the status says [#what-the-status-says] | Status | What it means | | --------------- | ------------------------------------------------------------------------------- | | **Idle** | No device selected yet | | **Connecting…** | Getting hold of the device's output | | **Live** | Connected — lines arrive as they happen | | **Stopped** | The connection ended, either because you clicked **Stop** or because it ran out | | **Error** | Something went wrong on the way. Click **Reconnect** to try again | Nothing is showing up [#nothing-is-showing-up] If the log stays empty, work through these in order: 1. **Is the app open on the device?** The output only exists while the app is running 2. **Is the status Live?** If it says **Stopped** or **Error**, click **Reconnect** 3. **Is a filter hiding things?** Clear the **Filter…** field and check whether lines appear 4. **Is it starti.app's own output you're after?** Turn on **Show starti.app log** 5. **Right device?** If several testers use similar phones, check the name in the picker If it's still empty, the app may simply not be writing anything out at that moment — that in itself is useful to know. A console log can contain whatever the app happens to write out, which may include details about a user. Treat a copied log as you would any other internal material, and don't paste it somewhere public. *** See also [#see-also] Add a device as a test device so it can be selected here Find a specific user and the devices they use the app on # Download test version of the app Download test version of the app [#download-test-version-of-the-app] What is this page for? [#what-is-this-page-for] On the [Test access](https://manager.starti.app/test-access) page in starti.app Manager you can find links to download the **test version** of your app — both on iOS and Android. The test version is a completely separate app from the one your users can download, and it is used when you want to check that app updates look and work correctly before changes reach your regular users. The test version is used internally during the initial development phase, and once the real app (what we call the Production app) is live for users, the test version is primarily used by developers when making changes to the app. You can also manage who has access to download the test version by adding and removing testers. The test version is different from the production version available in the App Store and Google Play. Changes you test here are not visible to regular users until you have published them. Want to test push notifications or Introflow? That is done via [Test devices](/en/academy/manager/getting-started/test-devices) — there is a separate guide for that. *** iOS — TestFlight [#ios--testflight] Apple's test distribution service is called **TestFlight**. On the iOS card you will find: * **A link to the test version** — copy it with the copy button, or scan the QR code with a phone to open it directly * **A list of testers** — people who have been given access to the test version How iOS testers get access [#how-ios-testers-get-access] There are two ways to give people access to the iOS test version: If Apple has approved the app for external testing, anyone with the TestFlight link can download the test version without being added in advance. The downside is that Apple reviews each new version before it becomes available — this typically takes a few business days. You can add testers manually by entering their name and email address. They are then added in App Store Connect and get access to new versions immediately — without waiting for Apple's review. The process is: 1. The tester receives an invitation from App Store Connect and **accepts it** via the link in the email 2. The tester receives a new email with a link to install the TestFlight app on their iPhone 3. The tester **opens TestFlight** and downloads the test version The email address must be the one the tester is logged in with on their iPhone (their Apple ID). Add an iOS tester [#add-an-ios-tester] 1. Go to **[Test access](https://manager.starti.app/test-access)** in the left-hand menu bar 2. Find the **iOS** card 3. Enter the tester's **name** and **email address** 4. Click **Add** After adding a tester, it typically takes approximately 15 minutes before they can actually download the app. While the task is being handled, you will see a yellow clock icon next to the tester's name. *** Android — Google Play [#android--google-play] On the Android card you will find: * **A link to the test version** — copy it with the copy button, or scan the QR code with a phone to open it directly * **A list of testers** — people who have been given access to the test version How Android testers get access [#how-android-testers-get-access] On Android, testers must be added by email before the Google Play link works for them. Once enrolled, they can download the test version directly via the link or by scanning the QR code. The email address must be the one the tester is logged in with on their Android device (their Google account). Add an Android tester [#add-an-android-tester] 1. Go to **[Test access](https://manager.starti.app/test-access)** in the left-hand menu bar 2. Find the **Android** card 3. Enter the tester's **name** and **email address** 4. Click **Add** After adding a tester, it typically takes less than one business day before they can actually download the app. While the task is being handled, you will see a yellow clock icon next to the tester's name. *** The yellow clock icon — what does it mean? [#the-yellow-clock-icon--what-does-it-mean] A yellow clock icon next to a tester's name means that the tester is awaiting approval. The tester must be approved before they can download the test version. Approval happens automatically, and unfortunately there is nothing you can do to speed up the process. iOS users are typically approved within 15 minutes, while Android users may wait up to one business day. *** See also [#see-also] Share a single link to your app that works on iPhone, Android, and PC Add a device as a test device so you can test push notifications and Introflow # Initialization settings Initialization settings [#initialization-settings] What are initialization settings? [#what-are-initialization-settings] Initialization settings control how starti.app behaves when your app starts up and while it runs. From this page you can decide which gestures users are allowed to use (like zoom and rotation), how the status bar looks, how the safe areas at the top and bottom of the screen are handled, and whether a loading spinner is shown while pages load. The page is split into three sections: **In-app behaviour**, **Status bar & safe areas**, and **Spinner**. Your changes are saved automatically as you make them. You can follow the save status in the top bar of starti.app Manager. Saving your changes is not enough on its own — you also need to **publish** them before they appear in the app. See [Publish your changes](#publish-your-changes) below. In-app behaviour [#in-app-behaviour] Under **In-app behaviour** you control what the user is allowed to do inside the app's web view. Turn each option on or off with its checkbox: * **Allow zoom in the app** — lets users pinch to zoom in and out. * **Allow screen rotation in the app** — lets the app rotate when the user turns their phone. * **Allow dragging of elements in the app** — lets users drag elements (for example images) around in the app. * **Allow overscroll bounce effect** — the "bounce" that happens when you scroll past the top or bottom of a page. * **Allow highlighting of elements and text in the app** — lets users select and highlight text. * **Allow swipe navigation between pages** — lets users swipe left and right to move back and forward between pages. For most apps that should feel like a native app, it's common to turn off zoom, the overscroll bounce, and text highlighting, so the app feels less like a website. Try the options that fit your app best. Status bar & safe areas [#status-bar--safe-areas] The status bar is the strip at the very top of the phone where the clock, battery, and signal are shown. The safe areas are the spaces at the top and bottom of the screen that sit around the notch, the home indicator, and rounded corners. Status bar [#status-bar] * **Hide status bar text** — hides the clock, battery, and other text in the status bar. * **Dark status bar content** — makes the status bar text and icons dark instead of light. Use this when your app has a light background at the top, so the text stays readable. * **Light status bar content** — makes the status bar text and icons light. Use this when your app has a dark background at the top, so the text stays readable. * **Auto** — lets the app choose light or dark status bar content automatically, based on the background colour of the top safe area. Auto can't be used when you remove the top safe area. In that case, choose **Dark** or **Light** manually. Upper and lower safe area [#upper-and-lower-safe-area] You can configure the top and bottom safe areas separately. For each one you have: * **Remove safe area** — removes the safe area on that side, so your content extends all the way to the edge of the screen. * **Background color** — the color shown in the safe area. Pick a color that matches your app's design. (This is only used when the safe area is kept, not removed.) Safe areas control how the app's content lines up with the physical edges of the phone. If you want to fine-tune this from the code side of your app, you can read more about the safe-area settings in the [SDK documentation](https://docs.starti.app/sdk/getting-started/setup-and-the-basics#css-custom-properties-safe-area-insets). Spinner [#spinner] The spinner is a small loading animation shown while a page is loading. This section is at the bottom of the page. * **Show spinner** — turns the loading spinner on or off. * **Show spinner after (ms)** — how long to wait before the spinner appears, in milliseconds. A small delay means the spinner won't flash on screen for pages that load quickly. For example, `500` means the spinner only appears if a page takes longer than half a second to load. * **Spinner color** — the color of the spinner. Pick a color that fits your app's design. * **Exclude spinner on domains** — a comma-separated list of website addresses where the spinner should *not* be shown (for example `example.com, shop.example.com`). The spinner options for delay, color, and excluded domains only apply when **Show spinner** is turned on. Publish your changes [#publish-your-changes] Your settings are saved as you edit them, but they won't reach the app until you publish them. At the top of the page you'll find two buttons: * **Publish to Development** — publishes your changes to the development app. This is used for testing your changes. The development app is a separate app (not the final app your users have) that looks like your app and lets you test changes before they reach your users. You need the development app to see your changes here. Read more in [Download test version of the app](/en/academy/manager/development-setup/download-test-app). * **Publish to Production** — publishes your changes to the real app that your users have. It takes about 10–15 minutes from when you click one of the buttons until your changes appear in the app. Remember to publish to Development or Production — otherwise your changes will not become visible in the app. See also [#see-also] Add the HTML snippet to your website to integrate the app Get the test version of your app to check your changes before your users see them # Splash screen Splash screen [#splash-screen] What is a splash screen? [#what-is-a-splash-screen] The splash screen is what users see for the brief moment while your app starts up and loads content. On this page you decide how it looks. Your changes are saved automatically as you make them. You can follow the save status in the top bar of starti.app Manager. Changes to your splash screen require starti.app to publish a new version of your app before your users will see them. Your changes are saved right away, but the updated app is published by starti.app afterwards. How the app starts (preview) [#how-the-app-starts-preview] At the top of the page there's a preview called **How the app starts**. Press play, or drag the timeline, to see the whole startup as the user experiences it — built from your own colors and images. Use it to check that everything looks right as you make changes. Choose a splash type [#choose-a-splash-type] Under **Splash type** you choose what is shown while the app loads the website. There are three types, and they are mutually exclusive — only the selected type is built into the app. Icon + spinner [#icon--spinner] The classic one. The icon lifts and a spinner appears beneath it. You can set the **spinner color** to match your app's design. Icon + Lottie [#icon--lottie] A **Lottie animation** replaces the icon and plays while waiting. Upload your animation (a Lottie `.json` file) if you want a more lively startup. Two settings decide how it plays: * **Repeat the animation** — the animation runs on a loop for as long as the app is starting, so it doubles as a loading indicator. Leave it off for animations that build your logo and are meant to end on it. * **Fill the screen** — see below. Fill the screen [#fill-the-screen] Instead of sitting at the size of your app icon, the animation can cover the whole screen. Turn on **Fill the screen** and the animation is scaled up until it covers the display, exactly like the fullscreen image does — and like that image, it is cropped to fit, so keep anything important near the middle. When it fills the screen, you also get the progress bar and status texts described under [Fullscreen image](#fullscreen-image), because there is no longer a spinner to show that the app is working. Manager tells you if your animation is much wider than a phone screen, and how much of it would be cropped away. If you see that warning, ask your designer for a portrait version. Making an animation that fills the screen [#making-an-animation-that-fills-the-screen] Pass these on to whoever creates the animation. They are not preferences — an animation that ignores them will look wrong on the phone. * **Portrait, around 1320 × 2868 px.** That is roughly the tallest phone the app runs on. * **The first frame must be the background only, with no logo or mark on it.** The phone's own start screen is generated from that first frame, so whatever the animation opens on is what the phone shows before the app can draw anything. If the first frame contains your logo, users see the logo appear, then jump. * **No effects layers.** The app plays Lottie animations with a player that ignores After Effects effects entirely — glow, blur, drop shadows and the like simply disappear. A glow has to be built as a real gradient shape instead. * **It should end where it starts** if you turn on **Repeat the animation**, otherwise the loop visibly jumps each time it restarts. * **Keep the file under 2 MB.** Manager rejects anything larger. [Download an example animation](/examples/fullscreen-splash-example.json) that follows all of the above. Open it in Manager to see how it behaves, or hand it to your designer as a reference. Alternative animations [#alternative-animations] Under **Alternative animations** you can add extra animations on top of the one above, and give each of them a name. Your website chooses between them by name — your developer calls `startiapp.App.setSplashVariant("name")` — so the app can open differently for, say, someone who is signed in than for a first-time visitor. Passing `null` goes back to the animation above. A few things are worth knowing before you add one: * **The animation above is still the default.** Every first launch, and everyone your website has not chosen for, gets it. * **The choice applies from the next launch.** The app has already drawn its splash by the time your website can say which one it wanted, so a new choice is what the user sees the next time they open the app. * **The name is not a label.** It is what your website passes in, so pick it together with whoever builds the site: lowercase letters, numbers and hyphens, starting with a letter. Renaming it later means the site has to pass the new name. * **The phone's own start screen always comes from the default animation's first frame.** It is drawn before the app knows which variant is about to play, so an alternative animation should open on the same background as the default one — otherwise users see a jump. * **A new animation needs a new version of the app.** The animations are built into the app, so starti.app has to publish an update before your users can be shown one. Only the **Icon + Lottie** type has alternative animations. The other two types have a single splash for everyone. Fullscreen image [#fullscreen-image] Your own image covers the whole screen, with a slow zoom and a progress bar. When you choose this type, you can set up the following: * **Fullscreen image** — upload a portrait image (PNG or JPG), at least 1320 × 2868 px (ratio approx. 9:19.5). The image is cropped to cover the whole screen, so keep important elements away from the edges. * **Slow zoom** — the image zooms in very slowly, so the screen is never completely static. * **Progress bar** — a thin line at the bottom that follows the app's actual startup. You can choose its color. * **Status texts** — short texts shown only if the startup takes a while (e.g. "Loading content…"). The texts are automatically translated into the languages your brand is set up for, and the app picks based on the device language. Leave the fields empty to show no text at all. * **Text appearance** — you can adjust the text's color, position, and the strength of the background behind the text, so it stays easy to read on top of the image. If a type shows the label **Needs setup**, something still needs to be filled in before that type can be used — for example a missing image or a missing animation. Icon and background [#icon-and-background] Before your splash screen can be drawn at all, the phone shows a start screen of its own. You do not control it directly — the phone draws it from what was built into the app — but under **Icon and background** you decide what it is built from: * By default, the splash screen uses your **app icon**. Read how to add your app icon [here](/en/academy/manager/development-setup/app-icon). * If you'd rather show a different image, tick **Use separate splash screen icon** and upload an image (PNG or SVG). * For a fullscreen image, you can also choose a **background color** close to the edges of the image, so the hand-off from the phone's start screen is smooth. When your animation fills the screen [#when-your-animation-fills-the-screen] Here you don't have to set anything up. The phone's start screen is generated from the animation's own first frame when you upload it, so the two match by construction — the animation continues from the picture the phone has already drawn, with no icon and no jump. **On Android 12 and newer this can only be approximated.** Those versions of Android allow exactly one solid colour on the start screen and nothing else — no image, no gradient — and they always draw an icon. So Android shows the average colour of your first frame and then fades into the animation, while iPhones show the frame itself. Manager displays the colour it will use. If the two need to look as close as possible, a first frame with an even, flat-ish background will match far better than a strongly graded one. See also [#see-also] Upload your app icon and adjust how it looks on iOS and Android Control how your app behaves when it loads — gestures, status bar, safe areas, and the loading spinner # Integrate your app with your website Integrate your app with your website [#integrate-your-app-with-your-website] What is website setup? [#what-is-website-setup] Website setup gives you the HTML code snippet that you need to insert on your website to integrate your starti.app app with your site. The snippet is unique to your brand and is generated automatically — you simply copy it and paste it into your website. The integration allows your website and your app to work closely together. Before you begin [#before-you-begin] Make sure you have access to the website's source code or a CMS that allows you to edit the HEAD element. If you're unsure, you can contact the person who manages your website. Contact us if the snippet is not shown in starti.app Manager — this may mean it hasn't been generated for your brand yet. How to find and copy your snippet [#how-to-find-and-copy-your-snippet] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Website setup [#open-website-setup] Go to **[Website setup](https://manager.starti.app/website-setup)** in the left-hand menu bar. Copy the HTML snippet [#copy-the-html-snippet] Under **HTML snippet**, the lines that need to be added to your website are shown. Click **Copy** to copy the entire snippet to the clipboard. If no snippet is shown, it hasn't been generated for your brand yet. Contact starti.app and we'll make sure to get it ready for you. Insert the snippet on your website [#insert-the-snippet-on-your-website] Paste the copied code snippet into the `` element of your website — preferably on all pages, unless you only want the integration on specific subpages. The change needs to be saved and published on the website before the integration is active. *** The snippet is unique to your brand. Always use the snippet shown under your own brand in starti.app Manager — do not copy it from another brand. If your website is built in a CMS such as WordPress, Webflow, or similar, there is typically a custom code field in the `` section under theme or page settings. This is where you insert the snippet. See also [#see-also] Create API keys to send push notifications programmatically # Add users to starti.app Manager How to add users to starti.app Manager [#how-to-add-users-to-startiapp-manager] What are Manager users? [#what-are-manager-users] Manager users are the people who have access to set up and manage your app via starti.app Manager. This could be colleagues who need to help send push notifications or update content in the app. Users are added with their email address and log in with Microsoft, Google, or Apple — just like you do. Be aware that when you add users in starti.app Manager, they get direct access to change content in your app. Therefore, only add users you trust. Add a new user [#add-a-new-user] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Manager users [#open-manager-users] Select **[Manager users](https://manager.starti.app/users)** in the menu, under **Administration**. Click "Add user" [#click-add-user] Click the **Add user** button in the top right corner of the overview. Enter the email address [#enter-the-email-address] Type the email address of the person you want to grant access to in the **Email** field. Then click **Create user** to save. The user will now appear in the overview under **Manager users** and can log in to starti.app Manager with the registered email address. The new user does not automatically receive an invitation email. Make sure to notify the person yourself that they now have access and that they should log in at [manager.starti.app](https://manager.starti.app/login). *** Manage your users [#manage-your-users] Go to **[Manager users](https://manager.starti.app/users)** in the menu to view and edit existing Manager users: * **Remove** a user by clicking the trash icon next to the user Deleting a user removes their access to Manager immediately. The action cannot be undone — but you can always add the user again. *** See also [#see-also] Get started setting up what new users first encounter in the app How to send a push notification to your users # Update App Store / Google Play How to fill in App Store / Google Play information [#how-to-fill-in-app-store--google-play-information] What can you do on the App Store / Google Play page? [#what-can-you-do-on-the-app-store--google-play-page] On this page in starti.app Manager, you fill in the text information shown about your app in the App Store (iOS) and Google Play (Android). This is the information potential users see when they find your app in the two app stores. The page is divided into eight sections: **App information**, **Contact details**, **Test account for review**, **Privacy policy**, **D-U-N-S number**, **Screenshots**, **Categories**, and **Age rating**. It is starti.app that uploads and updates your app in the App Store and Google Play. You cannot publish updates directly yourself — but by keeping this page up to date, you ensure that we always have the correct texts ready for the next release. *** App information [#app-information] Here you fill in the texts that describe your app in the two app stores. Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open App Store / Google Play [#open-app-store--google-play] Select **[App Store / Google Play](https://manager.starti.app/app-store-settings)** in the left-hand menu bar. Fill in App name [#fill-in-app-name] Enter the name of your app in the **App name** field. The app name is shown below the app icon in the App Store and Google Play and can be a maximum of **20 characters**. Fill in Subtitle [#fill-in-subtitle] Enter a short subtitle in the **Subtitle** field. The subtitle is only shown in the App Store (not Google Play) and can be a maximum of **30 characters**. Fill in Short description [#fill-in-short-description] Enter a short description of the app in the **Short description** field. The short description is only shown in Google Play (not the App Store) and can be a maximum of **80 characters**. Fill in Description [#fill-in-description] Enter a full description of your app in the **Description** field. The description is shown in both app stores when users click on the app's page. It can be a maximum of **4,000 characters**. Use the description to explain what the app does and who it is for. Write the most important information first — many users only read the first few lines. Add keywords [#add-keywords] Add relevant keywords in the **Keywords** field. Keywords are only used in the App Store (not Google Play) to help users find your app via search. The total number of characters across all keywords can be a maximum of **100 characters**. Choose keywords that potential users are likely to search for — and that are not already included in the app name or subtitle, as the App Store automatically includes these. All fields are saved automatically when you click to a new field. *** Contact details [#contact-details] Here you fill in the contact information shown in the App Store and Google Play, so users can contact you if they have questions. Fill in the **Email**, **Phone**, and **Website** fields with your preferred contact details. *** Test account for review [#test-account-for-review] When Apple and Google review your app before it's published, their reviewers need to be able to get into the app. If the app requires a login, you therefore need to provide a test account they can use to review the app. Fill in the fields: * **Username** — the username or email for the test account. * **Password** — the password for the test account. Click the eye to show or hide it while you type. * **Notes for the reviewer** — extra information the reviewer might need. For example, how to log in, which features require an account, or anything else that's good to know. If your app doesn't have a login, you can safely leave this section empty. *** Privacy policy [#privacy-policy] The App Store and Google Play require all apps to have a privacy policy with information about how user data is handled. Insert the URL to your privacy policy in the **URL** field under **Privacy policy**. The privacy policy is a requirement from both app stores. Without a valid URL, we cannot publish or update the app. *** D-U-N-S number [#d-u-n-s-number] The D-U-N-S number is a unique identification number for your company and is used by Apple to verify you as an app publisher in the App Store. Insert your D-U-N-S number in the **D-U-N-S** field. If you don't have a D-U-N-S number yet, you can apply for one for free via [Dun & Bradstreet](https://www.dnb.com/duns-number/get-a-duns.html). It can take up to 30 days to receive a new number. The D-U-N-S number is only used for the App Store and is not relevant for Google Play. *** Screenshots [#screenshots] Here you upload the screenshots users see on your app's page in the App Store and Google Play. They are sent to the stores together with the texts. Screenshots are per language — every language has its own set. So switch language in the language tabs at the top of the page before you upload. The section is split into two groups: **App Store (iPhone and iPad)** and **Google Play (Android)**. Each screen size is marked either **Required** or **Optional**. How to upload [#how-to-upload] Drag the images into the field below the screen size they belong to — or click **Choose screenshots** and find them on your computer. You can upload several at a time. * **Order:** drag the images to change the order. The order you set here is the one the store shows. * **Delete:** click the delete icon on the image. You will be asked to confirm. * **Optional sizes:** these are folded away behind the line "X more optional screen sizes". Click it to unfold them. The images must be PNG or JPEG and can be no larger than 10 MB per file. Both portrait and landscape are accepted. Requirements per size [#requirements-per-size] | Screen size | Store | Required? | Size in pixels | Number | | ------------------ | ----------- | ------------------------------------ | ---------------------------------------------------------------- | ------------------- | | iPhone 6.7"/6.9" | App Store | Yes, if the app is built for iPhone | 1290 × 2796 or 1320 × 2868 | Up to 10 | | iPad Pro 12.9"/13" | App Store | Yes, if the app supports iPad | 2064 × 2752 or 2048 × 2732 | Up to 10 | | iPhone 6.5" | App Store | No | 1242 × 2688 or 1284 × 2778 | Up to 10 | | iPhone 5.5" | App Store | No | 1242 × 2208 | Up to 10 | | Android phone | Google Play | Yes, if the app is built for Android | Recommended 1080 × 1920. Both sides must be between 320 and 3840 | At least 2, up to 8 | | Android tablet 7" | Google Play | No | Recommended 1200 × 1920 | Up to 8 | | Android tablet 10" | Google Play | No | Recommended 1600 × 2560 | Up to 8 | If an image has the wrong size, it is rejected straight away and you are told why. If screenshots are missing for a required size, the app cannot be submitted for review. Manager tells you at the top of the section which sizes are missing. The optional sizes are optional because the stores manage without them: the App Store scales the iPhone 6.7"/6.9" screenshots down to the smaller iPhone sizes itself, and Google Play shows the phone screenshots on tablets if you upload none. If you delete a screenshot, it disappears from Manager immediately — but it only disappears from the store the next time the content is sent to App Store Connect or Google Play. *** Categories [#categories] Categories decide where your app can be found in the App Store. They apply to the whole app — not per language — so you only need to choose them once. There are six fields you can fill in: * **Primary category** — the most important category for the app. This one is required. * **Primary subcategory 1** and **Primary subcategory 2** — a more precise placement within the primary category. * **Secondary category** — an extra category. This one is optional. * **Secondary subcategory 1** and **Secondary subcategory 2** — the same for the secondary category. The list of categories is fetched directly from App Store Connect, so it is always Apple's own, up-to-date list. Choose **None** in a field if you want to clear it. The primary category is required. Without it, the app cannot be submitted for review. Subcategories can only be chosen for categories that have any — otherwise the fields are locked. If you change a category, its subcategories are reset, because Apple only accepts subcategories belonging to the chosen category. Categories apply to the App Store only. Google Play's category is set directly in Play Console. *** Age rating [#age-rating] Apple has a questionnaire that decides which age rating your app gets in the App Store. Most of the form is about the app technology itself and is answered the same way for every app — we take care of that. In Manager you only get the ten questions where your own content decides the answer. Some of the questions are yes/no and are answered with a toggle bar. The rest have three levels to choose from: **None**, **Infrequent or mild**, and **Frequent or intense**. A short explanation of what each question covers sits below it. The ten questions are: | Question | Answer type | What it covers | | --------------------------------- | ------------ | ------------------------------------------------------------------------- | | Unrestricted web access | Yes/no | Whether the app can open any page on the web | | User-generated content | Yes/no | Reviews, comments, posts, or a forum inside the app | | Messaging and chat | Yes/no | Whether users can write to each other or to you — support chat counts too | | Advertising | Yes/no | Whether ads are shown inside the app | | Health and wellness | Yes/no | Exercise, diet, sleep, mental health, and similar | | Contests | Three levels | Contests, prize draws, and sweepstakes | | Medical and treatment information | Three levels | Illness, medication, and treatment | | Alcohol, tobacco and drugs | Three levels | Mentions or sales — a webshop selling wine or beer belongs here | | Weapons | Three levels | Mentions or sales of weapons, including hunting and outdoor equipment | | Sexual content or nudity | Three levels | In text, images, or video | The answers are a binding declaration to Apple. Apple compares them against the app that actually runs, and a wrong answer can cost the entire developer account in the worst case — not just a rejection. So always answer honestly. **Unrestricted web access** is switched on by default, and that is why the app ends up at 17+. It is the right answer for an app that shows your website, because nothing limits which pages it can open. Only switch it off if the app is locked to a fixed list of domains with no outgoing links. If the app is in the **Kids** category, the age rating must be filled in directly in App Store Connect. Apple requires an age band for kids' apps, and that cannot be set from here. The age rating applies to the App Store only. Google Play's content rating is filled in from Play Console. Your changes are saved automatically in Manager, but they are only sent on to the stores when a starti.app employee submits the app. *** Translate to other languages [#translate-to-other-languages] If your app is available in multiple languages, you need to translate all your texts into the desired languages. You do this just below the **App information** title, where you click on the desired language. You can either choose to translate one field at a time by clicking the globe icon next to the field, or you can click the "Translate all missing" button to translate all fields for the selected language. *** See also [#see-also] Give colleagues access to manage the app in Manager How to send a push notification to your users # Set up Introflow How to set up your Introflow [#how-to-set-up-your-introflow] What is an Introflow? [#what-is-an-introflow] The Introflow is the first thing new users encounter when they open the app for the first time. This is where you introduce the app, and where users are asked to accept push notifications and your privacy policy. A good Introflow increases the likelihood that users will say yes to push notifications — which is essential for being able to reach them with relevant messages later on. Before you begin [#before-you-begin] Make sure you have access to starti.app Manager. Contact us if you don't have access to your brand in starti.app Manager. Step-by-step guide to setting up your Introflow [#step-by-step-guide-to-setting-up-your-introflow] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Introflow [#open-introflow] Select **[Introflow](https://manager.starti.app/introflow)** in the left-hand menu bar. Add a step [#add-a-step] Click the step type you want to add as the first step in your Introflow. You can choose from five types: A completely free step where you decide the content yourself. Use it to introduce the app, highlight features, or give users a great start — with your own heading, text, and image. A step with preset configurations for asking users to accept push notifications. You fill in the heading and text shown on the step before the operating system's own pop-up appears. We recommend always adding this step, as it gives you the ability to send push notifications to your users in the future. A step with preset configurations for asking users to share their location. You fill in the heading and text shown on the step before the operating system's own pop-up appears. A step with preset configurations for asking users to accept your terms and privacy policy. You fill in all the input fields in the step yourself, such as title, terms, etc. A step with preset configurations for asking users to consent to cookies. The description text is automatically fetched from your cookie provider. You fill in the title and button texts yourself (e.g. "Accept all", "Reject all", and "Accept selected"). This step requires a supported cookie provider to be selected (e.g. Cookie Information). Fill in the fields [#fill-in-the-fields] Fill in the input fields for the step. The fields vary by type, but you will typically need to fill in: * **Title** — a short and catchy heading that tells the user what the step is about * **Description** — a short description that elaborates on the heading and motivates the user to continue * **Button text** — the text on the button that takes the user to the next step (e.g. "Next" or "Continue") Keep the text in each step short and action-oriented. Users are new to the app and need to be motivated to get started — not to read long explanations. Sort the steps [#sort-the-steps] Drag and drop the steps into the desired order using the icon with six dots on the left side of each step. Add a redirect URL [#add-a-redirect-url] Do you want users to land on a specific page in the app after completing the Introflow? If so, insert the desired URL in the **Redirect after completed Introflow** field. If you leave this field empty, users will be sent to the home page of your app. Choose a background [#choose-a-background] Below the steps you will find the **Background** section. Here you can place a graphic behind every step of the Introflow — the same background appears on each and every step. Add the graphic as SVG code in the **SVG code (optional)** field. SVG is a graphics format your designer can supply — you copy the contents of the file into the field. The graphic is scaled to fill the screen and is centred. Once the code is valid, you can see a small preview of the graphic just below the field. As soon as you have added a graphic, the **Opacity** slider appears. Drag it to make the background more or less visible — lower values turn it into a subtle watermark behind the content. The background is optional. If you leave the field empty, the Introflow simply uses the app's regular background colour. The graphic can be no larger than 32 KB. Anything bigger is rejected — ask your designer for a simpler version if that happens. Show or hide the step indicator [#show-or-hide-the-step-indicator] At the bottom of the page you will find the **Step indicator** section. The step indicator is the row of dots shown above each step, so users can see how far they have left in the Introflow. Use the **Show the step indicator** toggle bar to turn it on or off. It is on by default. The dots count only the steps each individual user actually sees. If a user has already accepted the terms, for example, they will see one dot fewer. The step indicator needs at least two steps to appear. If your flow only has one step, it won't show up — even with the toggle bar turned on. Choose when the Introflow appears [#choose-when-the-introflow-appears] Below the step indicator you will find the **Trigger** section. By default the Introflow opens by itself as soon as the app loads, and most brands should leave it that way. Turn on **Let my own code decide when the flow appears** if it should wait for something first — a sign-in, a splash screen, or a particular page. Nothing then appears until your website calls `startiapp.IntroFlow.show()`. This only moves the moment the Introflow opens. The same steps are shown, to the same users, in the same order — a user who has already been through it still doesn't see it again. With this turned on, nothing shows the Introflow but your own code. If the call is never made, no user ever sees it. See [Intro flow](/sdk/reference/intro-flow) in the SDK reference for the developer side. Translate your Introflow [#translate-your-introflow] If your app is available in multiple languages, remember to translate your Introflow into all selected languages. You switch languages at the top of the Introflow page in your Manager, and you can either translate one field at a time by clicking the globe icon next to the field — or click **Translate all missing** to translate all fields for the selected language. The numbers next to each language indicate whether there are missing translations. *** Test and publish your Introflow [#test-and-publish-your-introflow] Before publishing the Introflow, we recommend testing it on a test device. This way you can make sure the flow looks correct and works as expected — on both iOS and Android. If you haven't added test devices yet, you need to do so before testing. Read the guide: [Add test devices](/en/academy/manager/getting-started/test-devices) Once you have added and filled in all the necessary steps for your Introflow, test it before publishing. Test the Introflow as follows: Select **Deploy to Development** — this publishes ONLY to your internal test environment and does not reach users Open your app on your phone Shake the phone A test screen appears — select **Show development introflow** to start the test Check the following when testing: * Are images and text displayed correctly on screen? * Is the order of steps logical and easy to follow? * Do the buttons work? Do they take the user to the right step? When you have tested and are happy with the Introflow, you can publish it to production. Publish the Introflow as follows: Select **Deploy to Production** The Introflow is now active and will be shown to all new users who open the app for the first time. Note that it may take up to 15 minutes from when you publish the Introflow until it is active in the app. *** Checklist before you publish your Introflow [#checklist-before-you-publish-your-introflow] * Is the heading on each step short and catchy? * Is the text on each step short, precise, and motivating? * Are the images high resolution and relevant to the content? * If you chose a background: is the text still easy to read on top of it? * Are the button texts action-oriented and clear? * Have you included a step that asks users to accept push notifications? * Is the order of steps logical from the user's perspective? * Have you tested the flow on a test device — preferably on both iOS and Android? *** See also [#see-also] Segment your users with categories so they only receive relevant notifications How to create and send a manual push notification to your users # Microsoft login asks for admin approval How to handle the Microsoft admin-approval prompt [#how-to-handle-the-microsoft-admin-approval-prompt] If you (or a colleague) try to log in to **starti.app Manager** with Microsoft and see a message like: > **Approval required** — Ask your administrator to accept this app …this is **not a problem with starti.app**. The message comes from Microsoft. Your company's Microsoft 365 / Entra ID administrator has configured a rule that requires them to approve new sign-in apps before anyone in the company can use them. Your Microsoft (Entra ID) tenant decides whether new apps need admin approval. starti.app cannot bypass this — only your IT admin can approve it. What you can do [#what-you-can-do] You have two quick options as an end-user: * **Request approval from Microsoft.** In the same sign-in window, click **Request approval** (or the similar button Microsoft shows) and write a short reason — for example: *"I need this to manage our company app."* Your IT admin will then receive a request they can approve. This is usually the fastest path. * **Log in with Google or Apple — only if you have one tied to the same email.** If you also have a Google or Apple account that uses the **exact same email address** as your starti.app user, you can sign in with that provider instead and skip Microsoft entirely. * Most companies that use Microsoft 365 don't issue Google logins, so this option often isn't available. * With Apple sign-in, do **not** choose **Hide My Email** — it creates a relay address that won't match your starti.app user. If neither option works, forward the section below to whoever manages Microsoft 365 / Entra ID at your company. *** For your IT administrator [#for-your-it-administrator] To approve starti.app Manager for your organization in Microsoft Entra ID: Open the Entra admin center [#open-the-entra-admin-center] Go to [entra.microsoft.com](https://entra.microsoft.com) and sign in with an account that has the **Cloud Application Administrator** role (or higher, e.g. Privileged Role Administrator or Global Administrator). You also need to be a designated reviewer in the admin consent workflow. Approve pending consent requests [#approve-pending-consent-requests] Go to **Entra ID → Enterprise apps**, then under **Activity** select **Admin consent requests**. Open the **My Pending** tab, find the request for **starti.app Manager** (it may be listed under the underlying OAuth app name), select it, then choose **Approve**. Or pre-grant consent [#or-pre-grant-consent] If no request is pending, you can grant consent up-front: 1. Go to **Entra ID → Enterprise apps → All applications** 2. Search for and open **starti.app Manager** 3. Under **Security**, select **Permissions** 4. Review the requested permissions, then click **Grant admin consent** starti.app Manager only requests basic profile information (name, email, profile picture). It does not read or modify any other data in your Microsoft 365 tenant. *** Why does this happen? [#why-does-this-happen] Many organizations have a Microsoft Entra ID setting that blocks regular users from signing in to new apps without an admin's approval. This is a security feature, not a starti.app limitation. Once your IT admin approves starti.app Manager **once**, everyone in the company can log in without seeing the message again. *** See also [#see-also] How to add and manage users who should have access # Create push notification categories How to create push notification categories [#how-to-create-push-notification-categories] Before you begin [#before-you-begin] Categories allow you to segment your users so they only receive push notifications that are relevant to them. For example, you can create categories such as "News", "Offers", and "Order status" — and let users choose which ones they want to subscribe to. Users subscribe to categories directly in the app when they accept push notifications in your Introflow. If you haven't set up your Introflow yet, follow this guide: [Set up Introflow](/en/academy/manager/getting-started/introflow). For the technical documentation, read the guide: [Manage push topics in the app](/sdk/how-to/manage-push-topics). Step-by-step guide to creating a category [#step-by-step-guide-to-creating-a-category] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open categories [#open-categories] Select **Push notifications** in the left-hand menu bar and then click **[Categories](https://manager.starti.app/push-categories)**. Create a new category [#create-a-new-category] Type the name of your new category in the input field under "Add new category" and press **Add** or Enter. The category is created immediately and appears in the overview. When you create a category, it is automatically assigned a **Topic** — this is the internal name that a developer uses to add subscribers to the category and to send push notifications to them. The Topic name **cannot be changed** once it has been created, so make sure the category name is correct from the start. Choose whether the category should be visible in the app [#choose-whether-the-category-should-be-visible-in-the-app] All new categories are visible in the app by default. Uncheck **Show in app** if you want to hide the category from users — it can still be used for sending from Manager. If you choose to hide a category in the app, you will need to use the **Topic** instead to subscribe users to the category. This must be done by your developer. *** Manage your categories [#manage-your-categories] Go to **Push notifications -> [Categories](https://manager.starti.app/push-categories)** in the menu to edit existing categories: * **Rename** a category by clicking the pencil icon next to the category name * **Sort** categories in the desired order by dragging and dropping them using the 6 dots to the left of the category name * **Delete** a category by clicking the trash icon Deleting a category cannot be undone. Users who subscribe to the category will no longer receive notifications sent to it. *** See also [#see-also] How to send a push notification to your users Let users subscribe to and unsubscribe from push categories directly in the app # Add test devices How to add test devices in starti.app Manager [#how-to-add-test-devices-in-startiapp-manager] Before you begin [#before-you-begin] Test devices allow you to send a push notification or activate an Introflow on selected devices before sending it out to all your users. It's a great way to check that everything looks correct and that links work — across both iOS and Android. Want to test app content — such as new features or design changes in the app? That is done via [Download test version of the app](/en/academy/manager/development-setup/download-test-app) — there is a separate guide for that. To add a device as a test device, you need to have your app installed on the device and make sure you have the latest update to the app. How to add a test device [#how-to-add-a-test-device] The process takes place in two parts: first you activate the device from the app itself, and then you approve it in Manager. Open the app on the test device [#open-the-app-on-the-test-device] Make sure the app is open and running on the device you want to add as a test device. Shake the device [#shake-the-device] Shake the device while the app is open. Go to the home screen and open the app again [#go-to-the-home-screen-and-open-the-app-again] Go back to the home screen and then open the app again. It is not necessary to fully close the app. Shake the device again [#shake-the-device-again] Shake the device once more with the app open. This must all happen within 10 seconds. The device will then appear in Manager after approximately 30 seconds and is visible for up to 2 minutes. Log in to Manager [#log-in-to-manager] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Test devices [#open-test-devices] Select **[Test devices](https://manager.starti.app/test-devices)** in the left-hand menu bar. Find the device under "Available devices" [#find-the-device-under-available-devices] The device appears under **Available devices** approximately 30 seconds after you have completed the shaking. It is visible for up to 2 minutes — so you need to be ready to approve it. A colored bar below the device shows how much time is left before it disappears from the list. Add the device [#add-the-device] Click **Add** next to the device. Give the device a name that makes it easy to recognize — e.g. *Mikkel's iPhone* or *Test Android*. Then click **Add** to confirm. The device will now appear under **Current test devices**. Never add a device you don't recognize. We recommend adding at least one iOS device and one Android device as test devices. Since push notifications can look different on the two platforms, this gives you the best control over how the notification looks before sending it out. *** Manage your test devices [#manage-your-test-devices] Go to **[Test devices](https://manager.starti.app/test-devices)** in the menu to view and edit your registered devices: * **Rename** a device by clicking the pencil icon next to the device name * **Remove** a device by clicking the trash icon Removed test devices cannot be automatically restored. You will need to go through the registration process again if you want to add the device again. *** Where you can use your test devices [#where-you-can-use-your-test-devices] Test your push notification on selected devices before sending it out to everyone Preview and test your Introflow on a test device before publishing it Test your Welcome Flow on a test device before it goes live *** See also [#see-also] How to send a push notification — including a test to your test devices Segment your users with categories so they only receive relevant notifications # About push notifications About push notifications [#about-push-notifications] Push notifications are one of the most direct and effective tools available to you when you want to reach your users. Here you'll get an overview of what push notifications are, what they look like, and what you can use them for. *** What is a push notification? [#what-is-a-push-notification] A push notification is a short message sent directly to the user's phone — a bit like an SMS, but from an app. The message appears on the home screen, even if the user doesn't have the app open. A push notification is used to capture and retain the user's attention by delivering an important or relevant message at the right time. The goal is for the user to take the action you want. When the user taps the notification, they are taken directly into your app — ideally right to the product, offer, or page the message is about. That's why it's important that both the text and the link are well thought out, so the user is guided to the right place. *** What a push notification looks like [#what-a-push-notification-looks-like] A push notification typically consists of: * **Sender name and logo** — your app's name and icon * **Title** — a heading of max 30 characters * **Text** — a short description or call to action of max 90 characters * **Link** — an "invisible" link to the page in the app the notification should lead to **Push notifications appear in three places:** * On the **lock screen**, if the phone is locked * At the top of the **home screen** as a banner message, when the phone is in use * In the **notification center**, where the user can see all previous notifications *** Why use push notifications? [#why-use-push-notifications] Push notifications give you a direct communication channel to the user — you don't need to wait for them to open the app, visit your webshop, or read a newsletter. People check their phones far more often than they read emails. The message reaches the user immediately. You control the timing and set the agenda, without the user needing to do anything actively. With push notifications, you can remind users of offers, news, or content they've shown interest in — and bring them back again and again. Relevant messages based on user behavior — e.g. "You left something in your cart" or "A new video matching your interests has been added" — create a sense that you know and understand the user. This builds trust over time. If you use push notifications correctly, you'll see a clear spike in the number of active users in the app on the days you send notifications. *** Four types of push notifications [#four-types-of-push-notifications] Push notifications can be divided into four types based on what triggers them and what the purpose is. Sent because the user has done something specific — or hasn't done it. Set up to trigger automatically based on individual user behavior. **Example:** Trigger: The user has added items to their cart but hasn't completed the purchase. Message: *"Oops… You left something in your cart."* Goal: To get the user to complete the purchase. Automatic status updates about something happening in the system — e.g. changes to the user's order, status, or subscription. It's service, not marketing. The goal is to keep the user informed without them needing to check the app themselves. **Example:** Trigger: The user's order status changes. Message: *"Your package has been shipped 📦."* Goal: To inform about order status and create confidence. Sent to many users at once and not based on behavior, but on planned campaigns, offers, or general news. Used to create awareness, increase sales, or inform broadly. **Example:** Trigger: A campaign or piece of news you want to communicate. Message: *"Weekend offer! 30% off everything."* Goal: To increase sales and engagement. Tailored to the individual user based on their habits, interests, or data. Creates high relevance and engagement by targeting what the user actually cares about — and at the time they typically respond. **Example:** Trigger: The user's previous purchases, interests, or habits. Message: *"Your favorite category has new products 🧥."* Goal: To increase relevance and engagement with personally tailored messages. *** What should you measure? [#what-should-you-measure] KPIs (Key Performance Indicators) are the metrics you use to assess whether your push notifications are working. They help you figure out what's working and what needs to be adjusted. | KPI | What does it measure? | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Delivery rate** | How many notifications actually reach users' devices? Helps identify technical issues. | | **Open rate / click rate** | How many users open or click on the notification? Shows whether the content is relevant and engaging. | | **Conversion rate** | How many users take the desired action after clicking — e.g. a purchase or a sign-up? Measures the notification's direct business impact. | | **Unsubscribes** | How many choose to turn off push notifications? Indicates whether the frequency or content is disruptive. | Keep an eye on the number of active users in the app when you send a push notification. If you use push notifications correctly, you'll see a clear spike in activity on the days you send them. *** The good and the bad push notification [#the-good-and-the-bad-push-notification] The length of the text is crucial for how the message is displayed on the user's phone. **The good push notification** is short, clear, and fully visible — without being cut off. The entire message can be read directly on the screen, so the user immediately understands what it's about and what they should do. When the user taps the notification, they are taken directly to the relevant page in the app. **The bad push notification** is too long. The text cannot be displayed in full and is cut off with three dots ("..."), so the user can't read the full message. It also simply sends the user to the home page of the app — leaving them to find their own way to the part of the app the notification is about. *** See also [#see-also] Step-by-step guide to creating and sending a push notification in starti.app Manager Automate your first notifications to new users with a welcome flow # Notification sounds Notification sounds [#notification-sounds] By default, a push notification plays the phone's own sound. Under **Notification sounds** you can upload your own sounds, choose which sound notifications play by default, and pick a specific sound when you send a notification. Notification sounds is a feature we switch on for your brand. If you don't see **Notification sounds** in the menu, get in touch. How it works [#how-it-works] A notification sound lives inside the app itself. The phone doesn't download the sound when the notification arrives; it plays it from the app. That means: 1. You upload the sound in starti.app Manager. 2. The sound is included in the next version of the app you release. 3. Once the new version is out in the App Store and Google Play, notifications play the sound. Until the new version is out, notifications play the phone's own sound. The same goes for users who haven't updated the app yet. The notification still arrives as normal; it just doesn't have your sound yet. Upload a sound [#upload-a-sound] Open Notification sounds [#open-notification-sounds] Go to **[Notification sounds](https://manager.starti.app/notification-sounds)** under **Push notifications** in the menu. Choose a sound file [#choose-a-sound-file] Click **Upload sound** and choose the file. The sound must be: * a **WAV file** * at most **1 MB** * at most **30 seconds** long (iPhones don't play longer sounds) Some audio editors save WAV files in an encoding iPhones can't play. If starti.app Manager rejects your file for that reason, save it again as **PCM (16-bit)** in your audio editor. Name the sound [#name-the-sound] The name is what you pick the sound by. It may only contain lowercase letters, numbers and underscores, so starti.app Manager adjusts it for you, e.g. "Ding Dong" becomes `ding_dong`. If a sound with the same name already exists, it is replaced. Release a new version of the app [#release-a-new-version-of-the-app] The sound is now listed as **Awaiting new app version**. Once you have released a new version of the app and the stores have approved it, the status changes to **Ready**. **Ready on iOS** or **Ready on Android** means the new version is only out on one of the platforms so far. Choose the default sound [#choose-the-default-sound] At the top of the page, choose the **Default sound**. It plays on every notification where the sender didn't choose another sound, including those you send through the API and the welcome flow's notifications. Choose **Phone's default sound** to go back to the phone's own sound. Pick a sound when you send [#pick-a-sound-when-you-send] Once your brand has at least one sound, the form under [Send notification](/en/academy/manager/push-notifications/send-push-notification) gets a field where you choose the sound: * **Default sound** plays the default sound you chose. * **Phone's default sound** plays the phone's own sound, even if you chose another default sound. * Choosing one of your own sounds plays that sound. Sounds that aren't in the released app yet are marked **(awaiting new app version)**. Rename or delete a sound [#rename-or-delete-a-sound] Use the buttons next to the sound to play, rename or delete it. * **Rename:** The sound gets a new name, and that counts as a new sound. It has to be included in a new version of the app before it plays under the new name. * **Delete:** The sound is removed from the next version of the app. The current version keeps playing it until the new one is out. If the sound was your default sound, the default goes back to the phone's own sound. See also [#see-also] Create and send a push notification, with or without your own sound # View scheduled push notifications View scheduled push notifications [#view-scheduled-push-notifications] What are scheduled push notifications? [#what-are-scheduled-push-notifications] When you create a push notification and choose to send it at a specific time, it is queued as a scheduled notification. On this page you can view all your upcoming scheduled notifications and delete them if you change your mind. How to find your scheduled notifications [#how-to-find-your-scheduled-notifications] Go to **[Send notification](https://manager.starti.app/send-push)** in the menu and click the **[Scheduled](https://manager.starti.app/scheduled-notifications)** tab at the top of the page. The **Scheduled** tab shows a number when notifications are waiting in the queue, so you can see at a glance whether anything is lined up. Here you will see a list of all notifications scheduled to be sent at a future point in time. For each notification you can see: * **Title** of the notification * **Text** of the notification * **Date and time** of sending * **Recipients** — who the notification is scheduled to be sent to Delete a scheduled notification [#delete-a-scheduled-notification] If you want to cancel a scheduled notification before it is sent, you can delete it from the list. A deleted notification cannot be restored. If you change your mind, you will need to create the notification again. *** See also [#see-also] Create and send a push notification — or schedule it for a specific time View the history of all previously sent push notifications # How to send a push notification How to send a push notification [#how-to-send-a-push-notification] Before you begin [#before-you-begin] Make sure you have access to starti.app Manager. Push notifications are managed from starti.app Manager, and you need to be logged in with your brand to get started. Contact us if you don't have access to your brand in starti.app Manager. If this is your first time using starti.app Manager, you need to complete these guides before you can send push notifications: * [Create categories](/en/academy/manager/getting-started/push-categories) * [Set up Introflow](/en/academy/manager/getting-started/introflow) Step-by-step guide to sending a push notification [#step-by-step-guide-to-sending-a-push-notification] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Send notification [#open-send-notification] Go to **[Send notification](https://manager.starti.app/send-push)** in the menu. At the top of the page you'll find a tab strip with **New**, **Scheduled** and **Sent**. **New** is the page you are on now, where you create a notification. The two others take you to [the queue of upcoming notifications](/en/academy/manager/push-notifications/scheduled-notifications) and to [the history of everything you have sent](/en/academy/manager/push-notifications/sent-notifications). Select recipients [#select-recipients] Choose who should receive the notification: * Select **Send to all** to send to all users who have allowed push notifications * Select one or more **categories** to send only to a specific group of users You can see the number of subscribers for each option directly in the overview. If you haven't created categories in your app yet, follow this guide: [Create categories](/en/academy/manager/getting-started/push-categories) Fill in your push notification [#fill-in-your-push-notification] Fill in the fields: * **Title** (watch the length — an indicator shows you when the title risks being cut off on phones) * **Text** (like the title, an indicator shows the recommended text length) * **URL** — where should the notification take the user? If left empty, the notification links to the app's home page * **Sound** — only shown when your brand has [its own notification sounds](/en/academy/manager/push-notifications/notification-sounds). Leave it as it is and the notification plays your default sound Use the preview to check your message [#use-the-preview-to-check-your-message] Below the input fields, a live preview shows how the notification looks on a phone. Use it to make sure the title and text are not cut off, and that the message looks correct. Send or schedule your push notification [#send-or-schedule-your-push-notification] Once you've filled in the notification, you have three options: 1. Send it **right away** by clicking **Send push notification** 2. Send a **test to your own test devices** by selecting one or more test devices under "Or send to test devices" to the left of the input fields 3. **Schedule the notification** for a specific time by filling in **Date** and **Time** and then clicking **Send push notification** If you haven't added test devices yet, follow this guide: [Add test devices](/en/academy/manager/getting-started/test-devices) **"Schedule notification"** is a useful feature — for example for campaigns, offers, or launches that apply from a specific time. It can also be used if you know a notification needs to be sent tomorrow at 10am, but today is when you have time to create it. We recommend always testing your push notification on multiple internal test devices (preferably both iOS and Android) before sending it out to users. This way you can make sure the notification looks correct on different devices, and that the link takes the user to the right page in the app. *** Checklist before sending your push notification [#checklist-before-sending-your-push-notification] * Do you have a catchy title? * Do you have a text that briefly and precisely describes what it's about and what you want the user to do? * Does the URL work? Does it lead to the right place? * Have you selected the right recipients? The notification may not be relevant for all users. * Should the notification be sent immediately, or should it be scheduled for later? * If the notification is to be sent later — have you selected the right date and time? See also [#see-also] Segment your users with categories so they only receive relevant notifications # View sent push notifications View sent push notifications [#view-sent-push-notifications] What are sent push notifications? [#what-are-sent-push-notifications] On this page you will find an overview of the push notifications previously sent via starti.app Manager. It gives you a quick overview of what has been sent, when, and to whom. How to find your sent notifications [#how-to-find-your-sent-notifications] Go to **[Send notification](https://manager.starti.app/send-push)** in the menu and click the **[Sent](https://manager.starti.app/notifications)** tab at the top of the page. Here you will see a list of all previously sent notifications. For each notification you can see: * **Title** of the notification * **Text** of the notification — click to expand if the message is long * **Date** of sending * **Recipients** — how many the notification was sent to * **Topic** — which push category the notification was sent to (e.g. "All" or a specific category) * **Clicks and click rate** for each notification You can also **resend** a notification: click the resend icon on the right side of a row to open the send form with all fields already filled in. Change what you need and send it as a new notification. *** See also [#see-also] Create and send a push notification — or schedule it for a specific time View and manage your upcoming scheduled push notifications # How to set up a welcome flow How to set up a welcome flow with push notifications [#how-to-set-up-a-welcome-flow-with-push-notifications] What is a welcome flow? [#what-is-a-welcome-flow] When a user installs the app and accepts push notifications, they automatically receive a series of welcome notifications — we call this the Welcome flow. The flow helps engage users during the first critical days after installation, and gives you the opportunity to give them a great introduction to the app. Before you begin [#before-you-begin] Make sure you have access to starti.app Manager, and that the Welcome flow is available in your starti.app solution. Contact us if you don't have access to your brand in starti.app Manager, or if you'd like to upgrade your solution to include the Welcome flow. If this is your first time using starti.app Manager, you need to complete this guide before you can activate the Welcome flow: * [Set up Introflow](/en/academy/manager/getting-started/introflow) Welcome flow settings [#welcome-flow-settings] Before you start adding notifications, there are two settings you need to consider: If you activate this feature, the welcome flow will be sent to all existing users who have already installed the app and accepted push notifications. If you leave the feature disabled, the flow will only be sent to future new app users. Here you can set an overall rule for all notifications in your welcome flow — for example, that a notification is never sent out at night. Note that this feature does not determine the specific sending time for each individual notification. That is set on each notification individually. Step-by-step guide to adding a notification [#step-by-step-guide-to-adding-a-notification] Log in [#log-in] Open [starti.app Manager](https://manager.starti.app/login) and log in with Microsoft, Google, or Apple. Open Welcome flow [#open-welcome-flow] Go to **Push notifications** → **[Welcome flow](https://manager.starti.app/welcome-flow)** in the left-hand menu bar. Add a notification [#add-a-notification] Click **+ Add notification**, and then click the arrow to expand the notification. Choose the sending time [#choose-the-sending-time] Choose when the notification should be sent. You have two options: * **Same day as installation:** Select **Minimum wait time** and drag the slider to the number of minutes or hours that should pass before the user receives the notification. * **The day after or later:** Enter the number of midnights that should pass before the notification is sent. If you enter "1", the notification will be sent the day after installation. Remember to also specify what time of day it should be sent. Fill in your notification [#fill-in-your-notification] Fill in the fields: * **Title** (max 30 characters) * **Text** (max 90 characters) * **URL** — where should the notification take the user? If left empty, the notification links to the app's home page Add the next notifications [#add-the-next-notifications] Repeat the process for the remaining notifications in your flow. Note that the timing for each notification is calculated from the previous notification in the sequence — not from the installation time. **Example:** If you set notification 2 to send after 2 days, it will be sent 2 days after notification 1 — not necessarily 2 days after the user installed the app. We recommend starting with 5 notifications spread over the first 5 days. You can always add more notifications to the flow afterwards. *** Test your welcome flow [#test-your-welcome-flow] Before activating the welcome flow, it's a good idea to test it thoroughly. We recommend testing on multiple test devices — preferably both iOS and Android — to ensure users get the best experience regardless of their phone and operating system. If you haven't added test devices yet, follow this guide first: [Add test devices](/en/academy/manager/getting-started/test-devices) You can test in two ways, and we recommend going through both: You can send a test of each individual notification to a test device. Do this by expanding the notification, scrolling to the bottom, and clicking the arrow next to **Send test**. Select the desired test device and click **Send test**. Check the following when testing: * Is the title and text displayed in full, or is it cut off? Note that there may be differences in how the notification is displayed on iOS and Android. * Are there any spelling or typing errors? * Does the URL take the user to the right page in the app? When you test the entire flow together, you experience it exactly as your users will — with the real wait time between each notification. This gives you the opportunity to assess whether the overall journey makes sense. To test the entire flow, scroll down to the **Test welcome flow on device** section at the bottom of the page, select a test device, and click **Start welcome flow**. *** Activate your welcome flow [#activate-your-welcome-flow] When you have created and tested your welcome flow, you are ready to activate it. Click **Activate welcome flow** — the flow is activated immediately, and new users who accept push notifications are automatically queued to receive it. Only activate the welcome flow when you have completely finished creating and testing it. *** Checklist before activating your welcome flow [#checklist-before-activating-your-welcome-flow] * Have you created all the notifications you want in the flow? * Is there a catchy title on each notification? * Is the text on each notification short, precise, and action-oriented? * Do the URLs work? Do they lead to the right place in the app? * Is the order and timing logical from the user's perspective? * Have you tested the individual notifications on a test device? * Have you tested the entire flow together — preferably on both iOS and Android? * Have you decided whether existing users should be included? * Have you set a sending time that ensures notifications are not sent at night? See also [#see-also] How to create and send a manual push notification to your users Segment your users with categories so they only receive relevant notifications # What's new Here you can follow what's new across the whole starti.app platform. Each update is tagged with who it's most relevant for — **developers** or **marketing** — so you can filter down to what matters to you.