# 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). # 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 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, 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 # 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). # CDN & Brand Configuration CDN & Brand Configuration -- [#cdn--brand-configuration---] The starti.app SDK is served from a CDN at `cdn.starti.app`. Each brand gets its own URL namespace containing a customized bundle with brand-specific configuration baked in. This page explains how the distribution model works and what the brand name represents. The CDN URL pattern [#the-cdn-url-pattern] ``` https://cdn.starti.app/c/{BRAND_NAME}/main.js https://cdn.starti.app/c/{BRAND_NAME}/main.css ``` `{BRAND_NAME}` is the **brand identifier** you set up in the [starti.app Manager](https://manager.starti.app). It is a unique string that identifies your app and its configuration. When you load the SDK from this URL, you receive a bundle that includes: * The core starti.app SDK (the same for all brands) * Your brand-specific configuration (colors, domains, modules, features) * Any enabled factory modules (smart banners, intro flows, push notification buttons, etc.) This means the SDK is ready to use with your settings as soon as the script loads — no additional configuration API calls are needed. How builds work [#how-builds-work] When you update your brand configuration in the [starti.app Manager](https://manager.starti.app), the factory rebuilds your brand bundle and uploads it to the CDN. The build process: 1. Fetches the latest SDK version from a central repository 2. Reads your brand configuration from the database 3. Generates a brand-specific bundle combining the SDK with your configuration 4. Uploads the result to `cdn.starti.app/c/{BRAND_NAME}/` This happens automatically — you do not need to trigger builds manually. Brand registration [#brand-registration] Brands are created and managed in the [starti.app Manager](https://manager.starti.app). When you create a brand, you configure: * **Brand name / ID** — The unique identifier used in CDN URLs * **App URL** — The web application URL your native app loads * **Internal domains** — Domains that stay inside the app's webview * **Theme** — Primary and secondary colors * **Modules** — Optional features like smart banners, intro flows, cookie clickers * **Store configuration** — App Store and Google Play IDs The brand ID is the key that ties your CDN bundle, your native app, and your manager configuration together. In production CDN bundles are cached for up to 10 minutes. After you update your configuration in the Manager, changes propagate once the cache expires. Once a new version is published, the cache is disabled for the rest of the day. Content Security Policy (CSP) [#content-security-policy-csp] If your web application uses a Content Security Policy, you need to allow the starti.app CDN domain. Add these directives to your CSP header or `` tag: ``` script-src https://cdn.starti.app; style-src https://cdn.starti.app; connect-src https://api.starti.app; ``` Full CSP example [#full-csp-example] ```html ``` CSP directives summary [#csp-directives-summary] | Directive | Required value | Reason | | ------------- | ------------------------ | -------------------------------------------------------------- | | `script-src` | `https://cdn.starti.app` | SDK JavaScript bundle | | `style-src` | `https://cdn.starti.app` | SDK stylesheet | | `connect-src` | `https://api.starti.app` | SDK API calls (push notifications, analytics, etc.) | | `img-src` | `data: blob:` | Captured images (camera, file inputs) displayed as object URLs | If you do **not** use a CSP header, no action is needed — the SDK works without one. TypeScript types [#typescript-types] The CDN bundle does not include TypeScript types. If you use TypeScript, install the `starti.app` npm package for type definitions. See [TypeScript Support](/sdk/explanation/typescript-support) for details. # Domain Handling Domain Handling [#domain-handling] When your web app runs inside the starti.app container, every link the user taps needs to go somewhere. Some links should stay inside the main webview (internal), and some should open in an in-app browser (external). This page explains how the SDK decides which is which and how you can control it. The default behavior [#the-default-behavior] By default, only your app's starting domain is considered internal. Any link to an unknown domain opens in the in-app browser — a browser view that appears on top of your app, which the user can close to return. This prevents the user from accidentally navigating away from your content. ``` your-app.com/page → stays in the main webview (internal) maps.google.com → opens in the in-app browser (external) partner-site.com → opens in the in-app browser (external) ``` Internal domains [#internal-domains] Internal domains are loaded inside the app's webview. The user stays in the app and sees the content inline. Use this for domains that are part of your app experience, like a partner site or a payment gateway. ```typescript await startiapp.App.addInternalDomain("partner-site.com"); // Now partner-site.com loads inside the app ``` Internal domains are matched as exact strings against the host of the URL. External domains [#external-domains] External domains use **regex patterns** for flexible matching. This is useful when you want to force certain link patterns to always open in the in-app browser, even if they would otherwise match an internal domain. ```typescript await startiapp.App.addExternalDomains( /maps\.google\.com/, /\.pdf$/ ); ``` The SDK serializes the `RegExp` as `{ pattern, flags }` and sends it to the native layer, which applies the pattern against the full URL. External domains take **precedence** over internal domains. If a URL matches both an internal domain and an external pattern, it opens in the in-app browser. The "all internal" mode [#the-all-internal-mode] Sometimes you want everything to stay in the app. For example, if your app is a browser-like experience or aggregates content from many sources: ```typescript await startiapp.App.handleAllDomainsInternally(); ``` After this call, all domains are treated as internal. You can still force specific patterns to be external: ```typescript await startiapp.App.handleAllDomainsInternally(); await startiapp.App.addExternalDomains(/play\.google\.com/, /apps\.apple\.com/); ``` `handleAllDomainsInternally()` is a **session-only** setting. It resets when the app restarts. External domain rules persist normally. To go back to the default behavior: ```typescript await startiapp.App.restoreDefaultDomainHandling(); ``` Decision flowchart [#decision-flowchart] The navigatingPage event [#the-navigatingpage-event] Whenever the app navigates, the `navigatingPage` event fires with the target URL and whether it will open externally: ```typescript startiapp.App.addEventListener("navigatingPage", (event) => { console.log("Going to:", event.detail.url); console.log("External?", event.detail.opensExternalbrowser); }); ``` This is useful for analytics, logging, or dynamically adjusting behavior based on where the user is going. Opening a URL in the device's browser [#opening-a-url-in-the-devices-browser] If you need to open a URL in the device's actual browser (Safari, Chrome) — completely outside the app — use `openExternalBrowser`. This is the only way to open Safari or Chrome; external domains always open in the in-app browser, not the device's browser. ```typescript await startiapp.App.openExternalBrowser("https://maps.google.com/?q=Copenhagen"); ``` Practical patterns [#practical-patterns] Allow a payment gateway in-app [#allow-a-payment-gateway-in-app] ```typescript await startiapp.App.addInternalDomain("checkout.stripe.com"); // User completes payment without leaving the app // Remove when done: await startiapp.App.removeInternalDomain("checkout.stripe.com"); ``` Force store links to the in-app browser [#force-store-links-to-the-in-app-browser] ```typescript await startiapp.App.addExternalDomains( /apps\.apple\.com/, /play\.google\.com/ ); ``` Allow everything except downloads [#allow-everything-except-downloads] ```typescript await startiapp.App.handleAllDomainsInternally(); await startiapp.App.addExternalDomains(/\.pdf$/, /\.zip$/, /\.xlsx$/); ``` # TypeScript Support TypeScript Support [#typescript-support] The starti.app SDK is written in TypeScript and ships with full type definitions. You can get autocompletion, type checking, and inline documentation in any TypeScript-aware editor. Setup [#setup] Install the types package [#install-the-types-package] Install the `starti.app` npm package as a dev dependency. This provides the type definitions — the actual SDK is still loaded from the CDN at runtime. ```bash npm install --save-dev starti.app ``` Load the SDK from CDN [#load-the-sdk-from-cdn] The SDK itself is loaded via a script tag, not imported as a module. Add it to your HTML `` as usual: ```html ``` 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. 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 always 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 `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) for the full list of options. 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.

``` 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 **all** web data the app has accumulated, including cookies, localStorage, cache, and session storage. ```javascript await startiapp.Storage.clearWebData(); console.log("All web data has been cleared."); ``` `clearWebData()` is a destructive operation. The user will be logged out of all web sessions, all cookies will be deleted, and all cached data will be gone. Use this for "reset app" or "clear cache" features — not for routine storage management. 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. 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 ``` Use `darkContent: "auto"` to let the app choose the content colour from the safe area background colour's brightness: ```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(); ``` 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: [], }, }); ``` 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. # 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 (internal) or open them in an in-app browser (external). 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 open 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. 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 and open in the in-app browser: ```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 force specific ones to open externally 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] If you need to open a URL in the device's actual browser (Safari, Chrome) — completely outside the app — use `openExternalBrowser()`. This is different from external domains, which open in the in-app browser. ```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. # 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 # 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) # 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) # 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"); } ``` *** 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. External domains 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 are treated as external. **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 safe area background colour's brightness. **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). **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"] ``` *** 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. }); ``` *** Types [#types] NavigatingPageEvent [#navigatingpageevent] ```typescript interface NavigatingPageEvent { url: string; opensExternalbrowser: boolean; } ``` VibrationIntensity [#vibrationintensity] ```typescript enum VibrationIntensity { Low = 0, Medium = 1, High = 2, Intense = 3, } ``` SetStatusBarOptions [#setstatusbaroptions] ```typescript type SetStatusBarOptions = SafeAreaSideOptions & { hideText: boolean; // true = dark content (for light backgrounds) // false = light content (for dark backgrounds) // "auto" = chosen from the safe area background colour's brightness darkContent: boolean | "auto"; advancedSafeAreaOptions?: AdvancedSafeAreaOptions; }; ``` SafeAreaSideOptions [#safeareasideoptions] ```typescript type SafeAreaSideOptions = | { removeSafeArea: false; safeAreaBackgroundColor: string } | { removeSafeArea: true }; ``` 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; } ``` 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; }; ``` # 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. | # 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"); } ``` *** 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); ``` *** 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"]); ``` 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 }` —The notification content. **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(); } ``` # 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 all web data stored by the app, including cookies, `localStorage`, and cache. **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; } ``` # 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. # 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. ```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 or topics. The request body is an array of notification objects, allowing you to send multiple notifications in a single request. Each notification must target either `userIds` or `topics`, not both. The `userIds` correspond to the IDs registered via [`startiapp.User.registerId()`](/sdk/reference/user#registerid-userid-promisevoid) on the client side. 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 } ] ``` Either `userIds` 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 '[ { "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 ``` Responses [#responses] An empty response indicates all notifications were sent successfully. If there are warnings (for example, a user ID that was not found), the response includes them: ```json { "message": "Notifications sent with warnings", "warnings": [ "User 'user789' not found" ] } ``` 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" } ] ] } ``` Returned when the API key is missing or invalid. # 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. Right-click on the QR code on the [Flowlink page](https://manager.starti.app/flowlink) in starti.app Manager 2. Select **"Save image as..."** from the menu 3. Save the image 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. 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 # 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. *** 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. 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`) * **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 # 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 # 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 Users [#open-users] Select **[Users](https://manager.starti.app/users)** in the left-hand menu bar. 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 **[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 four sections: **App information**, **Contact details**, **Privacy policy**, and **D-U-N-S number**. 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. *** 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. *** 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 "Save settings"). 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. 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? * 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 # Opdater App Store / Google Play Sådan udfylder du App Store / Google Play-oplysninger [#sådan-udfylder-du-app-store--google-play-oplysninger] Hvad kan du på App Store / Google Play-siden? [#hvad-kan-du-på-app-store--google-play-siden] På denne side i starti.app Manager udfylder du de tekstoplysninger, der vises om din app i App Store (iOS) og Google Play (Android). Det er de oplysninger, potentielle brugere ser, når de finder din app i de to app stores. Siden er opdelt i fire sektioner: **App informationer**, **Kontaktoplysninger**, **Privatlivspolitik** og **D-U-N-S nummer**. Det er starti.app, der uploader og opdaterer din app i App Store og Google Play. Du kan altså ikke selv udgive opdateringer direkte — men ved at holde denne side opdateret sørger du for, at vi altid har de korrekte tekster klar til næste udgivelse. *** App informationer [#app-informationer] Her udfylder du de tekster, der beskriver din app i de to app stores. Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn App Store / Google Play [#åbn-app-store--google-play] Vælg **[App Store / Google Play](https://manager.starti.app/app-store-settings)** i menubaren i venstre side. Udfyld App-navn [#udfyld-app-navn] Skriv navnet på din app i feltet **App-navn**. App-navnet vises under appens ikon i App Store og Google Play og må maksimalt være **20 tegn**. Udfyld Undertitel [#udfyld-undertitel] Skriv en kort undertitel i feltet **Undertitel**. Undertitlen vises kun i App Store (ikke Google Play) og må maksimalt være **30 tegn**. Udfyld Kort beskrivelse [#udfyld-kort-beskrivelse] Skriv en kort beskrivelse af appen i feltet **Kort beskrivelse**. Den korte beskrivelse vises kun i Google Play (ikke App Store) og må maksimalt være **80 tegn**. Udfyld Beskrivelse [#udfyld-beskrivelse] Skriv en fyldig beskrivelse af din app i feltet **Beskrivelse**. Beskrivelsen vises i begge app stores, når brugere klikker ind på appens side. Den må maksimalt være **4.000 tegn**. Brug beskrivelsen til at fortælle, hvad appen kan, og hvem den er til. Skriv de vigtigste informationer først — mange brugere læser kun de første linjer. Tilføj søgeord [#tilføj-søgeord] Tilføj relevante søgeord i feltet **Søgeord**. Søgeord bruges kun i App Store (ikke Google Play) til at hjælpe brugere med at finde din app via søgning. Det samlede antal tegn på tværs af alle søgeord må maksimalt være **100 tegn**. Vælg søgeord, som potentielle brugere sandsynligvis vil søge på — og som ikke allerede indgår i app-navnet eller undertitlen, da App Store automatisk inkluderer disse. Alle felter gemmes automatisk, når du klikker videre til et nyt felt. *** Kontaktoplysninger [#kontaktoplysninger] Her udfylder du de kontaktoplysninger, som vises i App Store og Google Play, så brugere kan kontakte jer, hvis de har spørgsmål. Udfyld felterne **E-mail**, **Telefon** og **Website** med jeres foretrukne kontaktoplysninger. *** Privatlivspolitik [#privatlivspolitik] App Store og Google Play kræver, at alle apps har en privatlivspolitik med information om, hvordan brugerdata behandles. Indsæt URL'en til jeres privatlivspolitik i feltet **URL** under **Privatlivspolitik**. Privatlivspolitikken er et krav fra begge app stores. Uden en gyldig URL kan vi ikke udgive eller opdatere appen. *** D-U-N-S nummer [#d-u-n-s-nummer] D-U-N-S nummeret er et unikt identifikationsnummer for jeres virksomhed og bruges af Apple til at verificere jer som app-udgiver i App Store. Indsæt jeres D-U-N-S nummer i feltet **D-U-N-S**. Har I ikke et D-U-N-S nummer endnu, kan I ansøge om et gratis via [Dun & Bradstreet](https://www.dnb.com/duns-number/get-a-duns.html). Det kan tage op til 30 dage at modtage et nyt nummer. D-U-N-S nummeret bruges kun til App Store og er ikke relevant for Google Play. *** Oversæt til andre sprog [#oversæt-til-andre-sprog] Hvis du har din app på flere forskellige sprog, skal du oversætte alle dine tekster til de ønskede sprog. Det gør du lige under titlen **App informationer**, hvor du trykker på det ønskede sprog. Du kan enten vælge at oversætte et enkelt felt ad gangen ved at trykke på globussen ud for feltet, eller du kan trykke på knappen "Oversæt alle manglende" for at oversætte alle felter til det pågældende sprog. *** Se også [#se-også] Giv kolleger adgang til at administrere appen i Manager Sådan sender du en push notifikation til dine brugere # Opsæt Introflow Sådan opsætter du dit Introflow [#sådan-opsætter-du-dit-introflow] Hvad er et Introflow? [#hvad-er-et-introflow] Introflowet er det første, nye brugere møder, når de åbner appen for første gang. Det er her, du introducerer appen, og det er her, brugerne bliver bedt om at acceptere push notifikationer og privatlivspolitik. Et godt Introflow øger sandsynligheden for, at brugerne siger ja til push notifikationer — og det er afgørende for, at du efterfølgende kan nå ud til dem med relevante beskeder. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Step by step guide til at opsætte dit Introflow [#step-by-step-guide-til-at-opsætte-dit-introflow] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Introflow [#åbn-introflow] Vælg **[Introflow](https://manager.starti.app/introflow)** i menubaren i venstre side. Tilføj en side [#tilføj-en-side] Tryk på den side-type, du gerne vil tilføje som det første trin i dit Introflow. Du kan vælge mellem fem typer: En helt fri side, hvor du selv bestemmer indholdet. Brug den til at introducere appen, fremhæve funktioner eller give brugerne en god start — med din egen overskrift, tekst og billede. Et trin med forudindstillinger til at bede brugerne om at acceptere push notifikationer. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Vi anbefaler, at du altid tilføjer dette trin, da det giver dig mulighed for fremover at sende push notifikationer til dine brugere. Et trin med forudindstillinger til at bede brugerne om at dele deres lokation. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Et trin med forudindstillinger til at bede brugerne om at acceptere dine vilkår og privatlivspolitik. Du udfylder selv alle inputfelterne i trinnet som titel, terms, osv. Et trin med forudindstillinger til at bede brugerne om at give samtykke til cookies. Beskrivelsesteksten hentes automatisk fra din cookie-udbyder. Du udfylder selv titel og knaptekster (fx "Accepter alle", "Afvis alle" og "Gem indstillinger"). Dette trin kræver, at du har valgt en understøttet cookie-udbyder (fx Cookie Information). Udfyld felterne [#udfyld-felterne] Udfyld inputfelterne for trinnet. Det er forskelligt, hvilke inputfelter der er til hver type, men du skal som oftest udfylde: * **Titel** — en kort og fængende overskrift, der fortæller brugeren, hvad trinnet handler om * **Beskrivelse** — en kort beskrivelse, der uddyber overskriften og motiverer brugeren til at fortsætte * **Knaptekst** — teksten på knappen, der sender brugeren videre til næste trin (fx "Næste" eller "Fortsæt") Hold teksten i hvert trin kort og handlingsorienteret. Brugerne er nye i appen og skal motiveres til at komme godt i gang — ikke læse lange forklaringer. Tilføj kategorier (valgfrit) [#tilføj-kategorier-valgfrit] Hvis du har oprettet kategorier til push notifikationer, kan du tilføje et trin, hvor brugerne selv vælger, hvilke kategorier de ønsker at abonnere på. Tryk på **+ Tilføj trin** og vælg **Vælg kategorier**. Hvis du endnu ikke har oprettet kategorier, skal du læse denne guide: [Opret kategorier til push notifikationer](/academy/manager/kom-godt-i-gang/push-kategorier) Sortér trinnene [#sortér-trinnene] Træk og slip trinnene i den ønskede rækkefølge ved hjælp af ikonet med de seks prikker i venstre side af hvert trin. Tilføj en redirect URL [#tilføj-en-redirect-url] Ønsker du, at brugerne skal lande på en bestemt side i appen efter, de har gennemført Introflowet? Så skal du indsætte den ønskede URL i feltet **Redirect efter gennemført Introflow**. Indsætter du ikke en URL i dette felt, bliver brugerne sendt til forsiden i din app. Oversæt dit Introflow [#oversæt-dit-introflow] Hvis din app findes på flere sprog, skal du huske at oversætte dit Introflow til alle valgte sprog. Du skifter sprog i toppen af Introflow siden i din Manager, og her kan du enten oversætte ét felt ad gangen ved at trykke på globus-ikonet ud for feltet. Du kan også trykke på **Oversæt alle manglende**, og så bliver alle felter for det pågældende sprog oversat. Tallene ud for hvert sprog indikerer, om der mangler oversættelser. *** Test og udgiv dit Introflow [#test-og-udgiv-dit-introflow] Inden du udgiver Introflowet, anbefaler vi, at du tester det på en testenhed. På den måde kan du sikre dig, at flowet ser korrekt ud og fungerer som forventet — både på iOS og Android. Hvis du endnu ikke har tilføjet testenheder, skal du gøre det inden du tester. Læs guiden: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) Når du har tilføjet og udfyldt alle nødvendige trin til dit Introflow, skal du teste det inden du udgiver det. Test Introflowet på følgende måde: Tryk på **Deploy** i øverste højre hjørne Vælg **Deploy til Udvikling** — denne udgiver KUN på dit interne testmiljø og når ikke ud til brugerne Åbn din app på mobilen Ryst mobilen En testside dukker op — vælg **Vis udviklings introflow** for at starte testen Tjek følgende, når du tester: * Vises billeder og tekst korrekt på skærmen? * Er rækkefølgen af trin logisk og nem at følge? * Virker knapperne? Sender de brugeren videre til det rigtige trin? * Fungerer kategori-valget korrekt, hvis du har tilføjet det? Når du har testet og er tilfreds med Introflowet, kan du udgive det til produktion. Udgiv Introflowet på følgende måde: Tryk på **Deploy** i øverste højre hjørne Vælg **Deploy til Produktion** Introflowet er nu aktivt og vil blive vist for alle nye brugere, der åbner appen for første gang. Vær opmærksom på, at der kan gå 15 minutter fra du udgiver Introflowet, til det er aktivt i appen. *** Tjekliste før du udgiver dit Introflow [#tjekliste-før-du-udgiver-dit-introflow] * Er overskriften på hvert trin kort og fængende? * Er teksten på hvert trin kort, præcis og motiverende? * Er billederne i høj opløsning og relevante for indholdet? * Er knapeteksterne handlingsorienterede og tydelige? * Har du inkluderet et trin, der beder brugerne om at acceptere push notifikationer? * Er rækkefølgen af trin logisk set fra brugerens perspektiv? * Har du testet flowet på en testenhed — gerne både iOS og Android? *** Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer Sådan opretter og sender du en manuel push notifikation til dine brugere # Opret kategorier til push notifikationer Sådan opretter du kategorier til push notifikationer [#sådan-opretter-du-kategorier-til-push-notifikationer] Før du går i gang [#før-du-går-i-gang] Kategorier giver dig mulighed for at segmentere dine brugere, så de kun modtager push notifikationer, der er relevante for dem. Du kan fx oprette kategorier som "Nyheder", "Tilbud" og "Ordrestatus" — og lade brugerne selv vælge, hvilke de ønsker at abonnere på. Brugerne abonnerer på kategorier direkte i appen, når de accepterer push notifikationer i dit Introflow. Hvis du endnu ikke har opsat dit Introflow, skal du følge denne guide: [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow). For den tekniske dokumentation, skal du læse guiden: [Administrer push topics i appen](/sdk/how-to/manage-push-topics). Step by step guide til at oprette en kategori [#step-by-step-guide-til-at-oprette-en-kategori] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn kategorier [#åbn-kategorier] Vælg **Push notifikationer** i menubaren i venstre side og tryk herefter på **[Kategorier](https://manager.starti.app/push-categories)**. Opret en ny kategori [#opret-en-ny-kategori] Skriv navnet på din nye kategori i inputfeltet under "Tilføj ny kategori" og tryk på **Tilføj** eller Enter. Kategorien oprettes med det samme og vises i oversigten. Når du opretter en kategori, får den automatisk tildelt et **Topic** — det er det interne navn, som en udvikler bruger til at tilføje abonnenter til kategorien og til at sende push notifikationer til dem. Topic-navnet kan **ikke ændres**, efter det er oprettet, så sørg for at kategorinavnet er korrekt fra starten. Vælg om kategorien skal vises i appen [#vælg-om-kategorien-skal-vises-i-appen] Alle nye kategorier er som standard synlige i appen. Fjern fluebenet i **Vis i app**, hvis du vil skjule kategorien for brugerne — den kan stadig bruges til afsendelse fra Manager. *** Administrer dine kategorier [#administrer-dine-kategorier] Gå til **Push notifikationer -> [Kategorier](https://manager.starti.app/push-categories)** i menuen for at redigere eksisterende kategorier: * **Omdøb** en kategori ved at klikke på blyantsikonet ud for navnet på kategorien * **Sorter** kategorier i den ønskede rækkefølge ved at trække og slippe dem på de 6 prikker til venstre for kategorinavnet * **Slet** en kategori ved at klikke på skraldespandikonet Sletning af en kategori kan ikke fortrydes. Brugere, der abonnerer på kategorien, vil ikke længere modtage notifikationer sendt til den. *** Se også [#se-også] Sådan sender du en push notifikation til dine brugere Lad brugere abonnere og afmelde push kategorier direkte i appen # Tilføj testmobiler Sådan tilføjer du testmobiler i starti.app Manager [#sådan-tilføjer-du-testmobiler-i-startiapp-manager] Før du går i gang [#før-du-går-i-gang] Testmobiler giver dig mulighed for at sende en push notifikation eller aktivere et Introflow på udvalgte enheder, inden du sender ud til alle dine brugere. Det er en god måde at tjekke, at alt ser korrekt ud, og at links virker — på tværs af både iOS og Android. For at tilføje en enhed som testmobil, skal du have din app installeret på enheden og sørge for, at du har den seneste opdatering til appen. Sådan tilføjer du en testmobil [#sådan-tilføjer-du-en-testmobil] Processen foregår i to dele: Først aktiverer du enheden fra selve appen, og derefter godkender du den i Manager. Åbn appen på testenheden [#åbn-appen-på-testenheden] Sørg for, at appen er åben og kører på den enhed, du vil tilføje som testmobil. Ryst mobilen [#ryst-mobilen] Ryst mobilen, mens appen er åben. Gå ud på hjemmeskærmen og åbn appen igen [#gå-ud-på-hjemmeskærmen-og-åbn-appen-igen] Gå ud til hjemmeskærmen og åbn herefter appen igen. Det er ikke nødvendigt at lukke appen helt ned. Ryst mobilen igen [#ryst-mobilen-igen] Ryst mobilen endnu en gang med appen åben. Det hele skal ske inden for 10 sekunder. Enheden vises herefter i Manager efter cirka 30 sekunder og er synlig i op til 2 minutter. Log ind i Manager [#log-ind-i-manager] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Testmobiler [#åbn-testmobiler] Vælg **[Testmobiler](https://manager.starti.app/test-devices)** i menubaren i venstre side. Find enheden under "Tilgængelige enheder" [#find-enheden-under-tilgængelige-enheder] Enheden dukker op under **Tilgængelige enheder** ca. 30 sekunder efter, du har gennemført Del 1. Den er synlig i op til 2 minutter — så du skal være klar til at godkende den. En farvet bjælke under enheden viser, hvor lang tid der er tilbage, inden den forsvinder fra listen. Tilføj enheden [#tilføj-enheden] Tryk på **Tilføj** ud for enheden. Giv enheden et navn, der gør den nem at genkende — fx *Mikkels iPhone* eller *Test Android*. Tryk herefter på **Tilføj** for at bekræfte. Enheden vises nu under **Nuværende testmobiler**. Tilføj aldrig en enhed, du ikke kender. Vi anbefaler, at du tilføjer mindst én iOS-enhed og én Android-enhed som testmobiler. Eftersom push notifikationer kan se forskelligt ud på de to platforme, giver det dig den bedste kontrol over, hvordan notifikationen ser ud inden udsendelsen. *** Administrer dine testmobiler [#administrer-dine-testmobiler] Gå til **[Testmobiler](https://manager.starti.app/test-devices)** i menuen for at se og redigere dine registrerede enheder: * **Omdøb** en enhed ved at klikke på blyantikonet ud for enhedens navn * **Fjern** en enhed ved at klikke på skraldespandikonet Fjernede testmobiler kan ikke gendannes automatisk. Du skal gennemgå registreringsprocessen igen, hvis du vil tilføje enheden på ny. *** Se også [#se-også] Sådan sender du en push notifikation — inkl. test til dine testmobiler Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Tilføj brugere til starti.app Manager Sådan tilføjer du brugere til starti.app Manager [#sådan-tilføjer-du-brugere-til-startiapp-manager] Hvad er Manager-brugere? [#hvad-er-manager-brugere] Manager-brugere er de personer, der har adgang til at opsætte og administrere din app via starti.app Manager. Det kan fx være kolleger, der skal hjælpe med at sende push notifikationer eller opdatere indhold i appen. Brugere tilføjes med deres e-mailadresse og logger ind med Microsoft, Google eller Apple — præcis som du selv gør. Vær opmærksom på, at når du tilføjer brugere i starti.app Manager, får de direkte adgang til at ændre indhold i din app. Tilføj derfor KUN brugere, du har tillid til. Tilføj en ny bruger [#tilføj-en-ny-bruger] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Brugere [#åbn-brugere] Vælg **[Brugere](https://manager.starti.app/users)** i menubaren i venstre side. Tryk på "Tilføj bruger" [#tryk-på-tilføj-bruger] Tryk på knappen **Tilføj bruger** i øverste højre hjørne af oversigten. Udfyld e-mailadressen [#udfyld-e-mailadressen] Skriv e-mailadressen på den person, du vil give adgang, i feltet **Email**. Tryk herefter på **Opret bruger** for at gemme. Brugeren vises nu i oversigten under **Manager-brugere** og kan logge ind i starti.app Manager med den registrerede e-mailadresse. Den nye bruger modtager ikke automatisk en invitationsmail. Sørg for selv at give personen besked om, at de nu har adgang, og at de skal logge ind på [manager.starti.app](https://manager.starti.app/login). *** Administrer dine brugere [#administrer-dine-brugere] Gå til **[Brugere](https://manager.starti.app/users)** i menuen for at se og redigere eksisterende Manager-brugere: * **Fjern** en bruger ved at klikke på skraldespandikonet ud for brugeren Sletning af en bruger fjerner vedkommendes adgang til Manager med det samme. Handlingen kan ikke fortrydes — men du kan altid tilføje brugeren igen. *** Se også [#se-også] Kom i gang med at opsætte det første, nye brugere møder i appen Sådan sender du en push notifikation til dine brugere # Generelt om push notifikationer Generelt om push notifikationer [#generelt-om-push-notifikationer] Push notifikationer er et af de mest direkte og effektive redskaber, du har til rådighed, når du vil nå dine brugere. Her får du et overblik over, hvad push notifikationer er, hvordan de ser ud, og hvad du kan bruge dem til. *** Hvad er en push notifikation? [#hvad-er-en-push-notifikation] En push notifikation er en kort besked, der sendes direkte til brugerens telefon – lidt som en sms, men fra en app. Beskeden vises på hjemmeskærmen, også selvom brugeren ikke har appen åbnet. En push notifikation bruges til at fange og fastholde brugerens opmærksomhed ved at levere en vigtig eller relevant besked på det rigtige tidspunkt. Målet er, at brugeren udfører den handling, du ønsker. Når brugeren trykker på notifikationen, sendes de direkte ind i din app – ideelt set lige til den vare, det tilbud eller den side, beskeden handler om. Derfor er det vigtigt, at både teksten og linket er gennemtænkt, så brugeren ledes til det rigtige sted. *** Sådan ser en push notifikation ud [#sådan-ser-en-push-notifikation-ud] En push notifikation består som regel af: * **Afsendernavn og logo** — din apps navn og ikon * **Titel** — en overskrift på maks 30 tegn * **Tekst** — en kort beskrivelse eller opfordring til handling på maks 90 tegn * **Link** — et "usynligt" link til den side i appen, notifikationen skal lede til **Push notifikationer vises på tre steder:** * På den **låste skærm**, hvis telefonen er låst * Øverst på **hjemmeskærmen** som en banner-besked, når telefonen er i brug * I **notifikationscentret**, hvor brugeren kan se alle tidligere notifikationer *** Hvorfor bruge push notifikationer? [#hvorfor-bruge-push-notifikationer] Push notifikationer giver dig en direkte kommunikationskanal til brugeren – du behøver ikke vente på, at de selv åbner appen, besøger din webshop eller læser et nyhedsbrev. Folk tjekker deres telefon langt oftere, end de læser mails. Beskeden når brugeren med det samme. Du bestemmer timingen og sætter dagsordenen, uden at brugeren skal gøre noget aktivt. Med push notifikationer kan du minde brugeren om tilbud, nyheder eller indhold, de har vist interesse for – og få dem til at vende tilbage igen og igen. Relevante beskeder baseret på brugerens adfærd – fx "Du glemte noget i kurven" eller "Der er kommet en ny video, som matcher dine interesser" – skaber en oplevelse af, at du kender og forstår brugeren. Det opbygger tillid over tid. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i antallet af aktive brugere i appen på de dage, du sender notifikationer ud. *** Fire typer push notifikationer [#fire-typer-push-notifikationer] Push notifikationer kan inddeles i fire typer baseret på, hvad der udløser dem, og hvad formålet er. Sendes, fordi brugeren har gjort noget bestemt — eller ikke har gjort det. Sættes op til at aktiveres automatisk af den enkelte bruger. **Eksempel:** Trigger: Brugeren har lagt varer i kurven, men ikke gennemført købet. Besked: *"Hov… Du glemte noget i din kurv."* Formål: At få brugeren til at gennemføre købet. Automatiske statusopdateringer om noget, der sker i systemet — fx ændringer i brugerens ordre, status eller abonnement. Det er service, ikke marketing. Formålet er at holde brugeren opdateret, uden at de selv skal tjekke appen. **Eksempel:** Trigger: Brugerens ordrestatus ændres. Besked: *"Din pakke er blevet sendt 📦."* Formål: At informere om ordrestatus og skabe tryghed. Sendes til mange brugere på én gang og er ikke baseret på adfærd, men på planlagte kampagner, tilbud eller generelle nyheder. Bruges til at skabe opmærksomhed, øge salg eller informere bredt. **Eksempel:** Trigger: Kampagne eller nyhed, du vil informere om. Besked: *"Weekendtilbud! 30 % på alt."* Formål: At øge salg og engagement. Tilpasses den enkelte bruger baseret på deres vaner, interesser eller data. Skaber høj relevans og engagement ved at ramme det, brugeren faktisk interesserer sig for — og på det tidspunkt, de typisk reagerer. **Eksempel:** Trigger: Brugerens tidligere køb, interesser eller vaner. Besked: *"Din yndlingskategori har fået nye produkter 🧥."* Formål: At øge relevans og engagement med personligt tilpassede beskeder. *** Hvad skal du måle på? [#hvad-skal-du-måle-på] KPI'er (Key Performance Indicators) er de nøgletal, du bruger til at vurdere, om dine push notifikationer virker. De hjælper dig med at finde ud af, hvad der fungerer, og hvad der skal justeres. | KPI | Hvad måler det? | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Leveringsrate** | Hvor mange notifikationer når faktisk frem til brugernes enheder? Hjælper med at afdække tekniske problemer. | | **Åbningsrate / klikrate** | Hvor mange brugere åbner eller klikker på notifikationen? Viser, om indholdet er relevant og engagerende. | | **Konverteringsrate** | Hvor mange brugere udfører den ønskede handling efter at have klikket — fx et køb eller en tilmelding? Måler notifikationens direkte effekt på forretningen. | | **Afmeldinger** | Hvor mange vælger push notifikationer fra? Indikerer, om frekvensen eller indholdet er forstyrrende. | Hold øje med antallet af aktive brugere i appen, når du sender en push notifikation ud. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i aktiviteten på de dage, du sender. *** Den gode og den dårlige push notifikation [#den-gode-og-den-dårlige-push-notifikation] Længden på teksten er afgørende for, hvordan beskeden vises på brugerens telefon. **Den gode push notifikation** er kort, tydelig og fuldt synlig – uden at blive afkortet. Hele beskeden kan læses direkte på skærmen, så brugeren straks forstår, hvad det handler om, og hvad de skal gøre. Trykker brugeren på notifikationen, sendes de direkte ind på den relevante side i appen. **Den dårlige push notifikation** er for lang. Teksten kan ikke vises fuldt ud og bliver afkortet med tre prikker ("..."), så brugeren ikke kan læse hele budskabet. Den sender desuden bare brugeren ind på forsiden af appen – og det er herefter op til dem selv at finde vej til den del af appen, notifikationen omhandler. *** Se også [#se-også] Trin-for-trin guide til at oprette og sende en push notifikation i starti.app Manager Automatiser dine første notifikationer til nye brugere med et velkomstflow # Sådan sender du en push notifikation Sådan sender du en push notifikation [#sådan-sender-du-en-push-notifikation] Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Push notifikationer styres fra starti.app Manager, og du skal være logget ind med dit brand for at komme i gang. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå disse guides før du kan sende push notifikationer: * [Opret kategorier](/academy/manager/kom-godt-i-gang/push-kategorier) * [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow) Step by step guide til at sende en push notifikation [#step-by-step-guide-til-at-sende-en-push-notifikation] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Send notifikation [#åbn-send-notifikation] Gå til **[Send notifikation](https://manager.starti.app/send-push)** i menubaren i venstre side. Vælg modtagere [#vælg-modtagere] Vælg hvem der skal modtage notifikationen: * Vælg **Send til alle** for at sende til samtlige brugere, der har tilladt push notifikationer * Vælg en eller flere **kategorier** for kun at sende til en bestemt gruppe af brugere Du kan se antallet af abonnenter for hver mulighed direkte i oversigten. Hvis du endnu ikke har oprettet kategorier i din app, kan du følge denne guide: [Opret kategorier](/academy/manager/kom-godt-i-gang/push-kategorier) Udfyld din push notifikation [#udfyld-din-push-notifikation] Udfyld felterne: * **Titel** (pas på længden — en indikator viser dig, hvornår titlen risikerer at blive afkortet på telefoner) * **Tekst** (ligesom titlen vises en indikator for anbefalet tekstlængde) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Brug preview til at tjekke beskeden [#brug-preview-til-at-tjekke-beskeden] Under inputfelterne vises et live preview af, hvordan notifikationen ser ud på en telefon. Brug det til at sikre, at titel og tekst ikke bliver afkortet, og at beskeden ser korrekt ud. Send eller planlæg din push notifikation [#send-eller-planlæg-din-push-notifikation] Når du har udfyldt notifikationen, har du tre muligheder: 1. Send den ud **med det samme** ved at trykke på **Send push notification** 2. Send en **test til dine egne testmobiler** ved at vælge en eller flere testenheder under "Eller send til testmobiler" til venstre for inputfelterne 3. **Planlæg notifikationen** til et bestemt tidspunkt ved at udfylde **Dato** og **Tid** og derefter trykke på **Send push notification** Hvis du endnu ikke har tilføjet testmobiler, skal du følge denne guide: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) **"Planlæg notifikation"** er en nyttig funktion — fx til kampagner, tilbud eller lanceringer, der gælder fra et bestemt tidspunkt. Funktionen kan også bruges, hvis du ved, at en notifikation skal sendes i morgen kl. 10, men det er i dag, du har tid til at oprette den. Vi anbefaler, at du altid tester din push notifikation på flere interne testenheder (gerne både iOS og Android), inden du sender den ud til brugerne. På den måde kan du sikre dig, at notifikationen ser korrekt ud på forskellige enheder, og at linket sender brugeren ind på den rigtige side i appen. *** Tjekliste før du sender din push notifikation [#tjekliste-før-du-sender-din-push-notifikation] * Har du en fængende titel? * Har du en tekst, der kort og præcist beskriver, hvad det handler om, og hvad du ønsker, at brugeren skal gøre? * Virker URL'en? Leder den det rigtige sted hen? * Har du valgt de rigtige modtagere? Det er måske ikke alle brugere, den enkelte notifikation er relevant for. * Skal notifikationen sendes med det samme, eller skal den planlægges til senere? * Hvis notifikationen skal sendes senere — har du valgt den rigtige dato og tid? Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Sådan opsætter du et velkomstflow Sådan opsætter du et velkomstflow med push notifikationer [#sådan-opsætter-du-et-velkomstflow-med-push-notifikationer] Hvad er et velkomstflow? [#hvad-er-et-velkomstflow] Når en bruger installerer appen og accepterer push notifikationer, modtager de automatisk en serie af velkomst notifikationer – det kalder vi for et Velkomstflow. Flowet hjælper med at engagere brugerne i de første kritiske dage efter installation, og det giver dig mulighed for at give dem en god introduktion til appen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager, og at Velkomstflowet er tilgængeligt i din starti.app løsning. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager, eller hvis du ønsker at opgradere din løsning, så Velkomstflowet er inkluderet. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå denne guide, før du kan aktivere Velkomstflowet: * [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow) Indstillinger for velkomstflowet [#indstillinger-for-velkomstflowet] Inden du begynder at tilføje notifikationer, er der to indstillinger, du skal tage stilling til: Aktiverer du denne funktion, bliver velkomstflowet sendt ud til alle de eksisterende brugere, der allerede har installeret appen og accepteret push notifikationer. Lader du funktionen være deaktiveret, bliver flowet kun sendt ud til fremtidige nye app-brugere. Her kan du sætte en overordnet regel for alle notifikationerne i dit velkomstflow – fx at der aldrig bliver sendt en notifikation ud om natten. Vær opmærksom på, at denne funktion ikke bestemmer det konkrete udsendelsestidspunkt for den enkelte notifikation. Det indstilles på hver enkelt notifikation. Step by step guide til at tilføje en notifikation [#step-by-step-guide-til-at-tilføje-en-notifikation] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Velkomstflow [#åbn-velkomstflow] Gå til **Push notifikationer** → **[Velkomstflow](https://manager.starti.app/welcome-flow)** i menubaren i venstre side. Tilføj en notifikation [#tilføj-en-notifikation] Tryk på **+ Tilføj notifikation**, og tryk derefter på pilen for at folde notifikationen ud. Vælg udsendelsestidspunkt [#vælg-udsendelsestidspunkt] Vælg hvornår notifikationen skal sendes. Her har du to muligheder: * **Samme dag som installation:** Vælg **Minimum ventetid** og træk slideren til det antal minutter eller timer, der skal gå, før brugeren modtager notifikationen. * **Dagen efter eller senere:** Indtast det antal midnat, der skal passere, før notifikationen sendes. Indtaster du "1", sendes notifikationen dagen efter installation. Husk også at angive, hvilket tidspunkt på dagen den skal sendes. Udfyld din notifikation [#udfyld-din-notifikation] Udfyld felterne: * **Titel** (maks 30 tegn) * **Tekst** (maks 90 tegn) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Tilføj de næste notifikationer [#tilføj-de-næste-notifikationer] Gentag processen for de øvrige notifikationer i dit flow. Vær opmærksom på, at tidspunktet for hver notifikation regnes ud fra den forrige notifikation i rækken – ikke fra installationstidspunktet. **Eksempel:** Sætter du notifikation 2 til udsendelse efter 2 dage, sendes den 2 dage efter notifikation 1 – og ikke nødvendigvis 2 dage efter, at brugeren installerede appen. Vi anbefaler, at du starter med 5 notifikationer fordelt over de første 5 dage. Du kan altid tilføje flere notifikationer til flowet efterfølgende. *** Test dit velkomstflow [#test-dit-velkomstflow] Inden du aktiverer velkomstflowet, er det en god idé at teste det grundigt. Vi anbefaler, at du tester på flere testenheder – gerne både iOS og Android – for at sikre, at brugerne får den bedste oplevelse uanset mobil og styresystem. Hvis du endnu ikke har tilføjet testenheder, skal du følge denne guide først: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) Du kan teste på to måder, og vi anbefaler, at du gennemgår begge: Du kan sende en test af hver enkelt notifikation til en testenhed. Det gør du ved at folde notifikationen ud, scrolle ned til bunden og trykke på pilen ud for **Send test**. Vælg den ønskede testenhed og tryk **Send test**. Tjek følgende, når du tester: * Bliver titel og tekst vist fuldt ud, eller bliver de afkortet? Husk, at der kan være forskel på, hvordan notifikationen vises på iOS og Android. * Er der stave- eller tastefejl? * Sender URL'en brugeren til den rigtige side i appen? Når du tester flowet samlet, oplever du det præcis som dine brugere kommer til at opleve det — med den reelle ventetid mellem hver notifikation. Det giver dig mulighed for at vurdere, om den samlede rejse giver mening. For at teste hele flowet skal du scrolle ned til sektionen **Test velkomstflow på enhed** nederst på siden, vælge en testenhed og trykke **Start velkomstflow**. *** Aktivér dit velkomstflow [#aktivér-dit-velkomstflow] Når du har oprettet og testet dit velkomstflow, er du klar til at aktivere det. Tryk på **Aktivér velkomstflow** — flowet bliver aktiveret med det samme, og nye brugere, der accepterer push notifikationer, sættes automatisk i kø til at modtage det. Aktivér først velkomstflowet, når du er helt færdig med at oprette og teste det. *** Tjekliste før du aktiverer dit velkomstflow [#tjekliste-før-du-aktiverer-dit-velkomstflow] * Har du oprettet alle de notifikationer, du ønsker i flowet? * Er der en fængende titel på hver notifikation? * Er teksten på hver notifikation kort, præcis og handlingsorienteret? * Virker URL'erne? Leder de til det rigtige sted i appen? * Er rækkefølgen og timingen logisk set fra brugerens perspektiv? * Har du testet de enkelte notifikationer på en testenhed? * Har du testet hele flowet samlet — gerne på både iOS og Android? * Har du taget stilling til, om eksisterende brugere skal inkluderes? * Har du sat et afsendelsestidspunkt, der sikrer, at notifikationer ikke sendes om natten? Se også [#se-også] Sådan opretter og sender du en manuel push notifikation til dine brugere Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Opret og administrer API nøgler Opret og administrer API nøgler [#opret-og-administrer-api-nøgler] Hvad er API nøgler? [#hvad-er-api-nøgler] API nøgler giver dig mulighed for at sende push notifikationer programmatisk — fx fra dit eget system, en webshop eller et automatiseringsværktøj — uden at du manuelt skal logge ind i starti.app Manager. Hver API nøgle er knyttet til dit brand og har tilladelse til at sende push notifikationer via starti.app's API. En API nøgle vises kun én gang, når den oprettes. Sørg for at gemme den et sikkert sted med det samme — du kan ikke hente den igen bagefter. Opret en ny API nøgle [#opret-en-ny-api-nøgle] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn API nøgler [#åbn-api-nøgler] Gå til **[API nøgler](https://manager.starti.app/api-keys)** i menubaren i venstre side. Start oprettelse [#start-oprettelse] Klik på **Opret ny nøgle** øverst til højre på siden. Giv nøglen et navn [#giv-nøglen-et-navn] Skriv et beskrivende navn til nøglen i feltet **Navn** — fx "Webshop integration" eller "Newsletter automation". Navnet hjælper dig med at holde styr på, hvilke systemer der bruger hvilken nøgle. Gem nøglen sikkert [#gem-nøglen-sikkert] Klik på **Opret nøgle**. Din nye API nøgle vises nu i en boks på skærmen. Klik på kopier-ikonet og gem nøglen et sikkert sted — fx i din adgangskodemanager eller som en miljøvariabel i dit system. Klik herefter på **Jeg har gemt nøglen** for at lukke dialogen. Nøglen vises kun denne ene gang. Har du ikke gemt den, skal du slette nøglen og oprette en ny. *** Administrer dine API nøgler [#administrer-dine-api-nøgler] Under **Aktive API nøgler** kan du se en oversigt over alle nøgler, der er oprettet til dit brand. For hver nøgle vises: * **Navn** — det navn, du gav nøglen ved oprettelsen * **Rettigheder** — hvad nøglen har adgang til (pt. `PushNotifications/Send`) * **Oprettet** — hvornår nøglen blev oprettet * **Oprettet af** — hvilken bruger der oprettede nøglen * **Sidst brugt** — hvornår nøglen sidst blev brugt til at kalde API'et * **Forbrug** — hvor mange gange nøglen har været brugt Slet en API nøgle [#slet-en-api-nøgle] Klik på skraldespandikonet ud for den nøgle, du vil slette, og bekræft sletningen i den dialog, der vises. Sletning af en API nøgle kan ikke fortrydes. Alle systemer, der bruger nøglen, vil øjeblikkeligt miste adgang og holde op med at fungere. Sørg for at opdatere dine systemer, inden du sletter en nøgle. *** Opret separate nøgler til hvert af dine systemer eller integrationer. Så kan du nemt tilbagekalde adgangen for ét system uden at påvirke de andre. Se også [#se-også] Lær at sende push notifikationer manuelt fra starti.app Manager Tilføj HTML-snippetten til din hjemmeside for at integrere appen # Integrer din app med din hjemmeside Integrer din app med din hjemmeside [#integrer-din-app-med-din-hjemmeside] Hvad er hjemmesideopsætning? [#hvad-er-hjemmesideopsætning] Hjemmesideopsætning giver dig det HTML-kodestykke (snippet), du skal indsætte på din hjemmeside for at integrere din starti.app-app med dit site. Snippetten er unik for dit brand og genereres automatisk — du skal blot kopiere den og sætte den ind på din hjemmeside. Integrationen gør det muligt for din hjemmeside og din app at arbejde tæt sammen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til hjemmesidens kildekode eller et CMS, der giver dig mulighed for at redigere i HEAD-elementet. Hvis du er i tvivl, kan du kontakte den, der administrerer din hjemmeside. Ring eller skriv til os, hvis snippetten ikke vises i starti.app Manager — det kan betyde, at den endnu ikke er genereret for dit brand. Sådan finder og kopierer du din snippet [#sådan-finder-og-kopierer-du-din-snippet] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Hjemmesideopsætning [#åbn-hjemmesideopsætning] Gå til **[Hjemmesideopsætning](https://manager.starti.app/website-setup)** i menubaren i venstre side. Kopiér HTML-snippetten [#kopiér-html-snippetten] Under **HTML snippet** vises de linjer, der skal tilføjes til din hjemmeside. Klik på **Kopier** for at kopiere hele snippetten til udklipsholderen. Hvis der ikke vises nogen snippet, er den endnu ikke genereret for dit brand. Kontakt starti.app, så sørger vi for at få den klar til dig. Indsæt snippetten på din hjemmeside [#indsæt-snippetten-på-din-hjemmeside] Sæt det kopierede kodestykke ind i ``-elementet på din hjemmeside — gerne på alle sider, medmindre du kun ønsker integrationen på bestemte undersider. Ændringen skal gemmes og udgives på hjemmesiden, før integrationen er aktiv. *** Snippetten er unik for dit brand. Brug altid den snippet, der vises under dit eget brand i starti.app Manager — kopier den ikke fra et andet brand. Hvis din hjemmeside er bygget i et CMS som WordPress, Webflow eller lignende, er der typisk et felt til custom code i ``-sektionen under tema- eller sideindstillingerne. Det er her, du skal indsætte snippetten. Se også [#se-også] Opret API nøgler til at sende push notifikationer programmatisk # 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 # 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[View scheduled notifications](https://manager.starti.app/scheduled-notifications)** () > under the Push notification section. 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 left-hand menu bar. 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 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[Sent notifications](https://manager.starti.app/notifications)** > under the Push notification section. 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 # 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. Right-click on the QR code on the [Flowlink page](https://manager.starti.app/flowlink) in starti.app Manager 2. Select **"Save image as..."** from the menu 3. Save the image 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. 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 # 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. *** 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. 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`) * **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 # 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 # 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 Users [#open-users] Select **[Users](https://manager.starti.app/users)** in the left-hand menu bar. 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 **[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 four sections: **App information**, **Contact details**, **Privacy policy**, and **D-U-N-S number**. 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. *** 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. *** 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 "Save settings"). 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. 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? * 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 # 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** 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** 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 # 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[View scheduled notifications](https://manager.starti.app/scheduled-notifications)** () > under the Push notification section. 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 left-hand menu bar. 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 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[Sent notifications](https://manager.starti.app/notifications)** > under the Push notification section. 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 # Introduktion import { Cards, Card } from 'fumadocs-ui/components/card'; import { Callout } from 'fumadocs-ui/components/callout'; Velkommen til starti.app Academy [#velkommen-til-startiapp-academy] starti.app Academy er stedet, hvor du lærer at bruge starti.app Manager fra bunden — og opbygger den viden, du har brug for til at drive din app professionelt. Uanset om du er ny på platformen eller bare vil blive skarpere på bestemte funktioner, finder du her trin-for-trin guides, der tager dig hele vejen igennem. *** Hvad lærer du? [#hvad-lærer-du] Akademiet er bygget op som et forløb, du kan følge fra start til slut — eller springe direkte til det, du har brug for. *** Hvad er du i stand til, når du er færdig? [#hvad-er-du-i-stand-til-når-du-er-færdig] Når du har gennemgået starti.app Academy, kan du: * **Sætte din app rigtigt op** fra dag ét — med de rigtige indstillinger, brugere og testmuligheder * **Sende push notifikationer** målrettet de rigtige brugere på det rigtige tidspunkt * **Oprette automatiske velkomstflows** for en god onboarding af nye brugere * **Følge med i din apps performance** via dashboardet og forstå, hvad tallene betyder * **Håndtere tekniske opsætninger** som hjemmesideforbindelser og API-nøgler Du behøver ikke gennemgå alt på én gang. Start med det, der er mest relevant for dig lige nu — og kom tilbage, når du er klar til mere. *** Hvem er Academy til? [#hvem-er-academy-til] Academy er primært til dig, der arbejder i **starti.app Manager** — platformen du bruger til at styre og kommunikere med dine app-brugere. Du behøver ikke have teknisk baggrund for at følge guiderne. Har du brug for den tekniske dokumentation til starti.app — f.eks. til SDK, API eller app-opsætning — finder du den i [starti.app Docs](/sdk). # 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 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, 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 # 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). # CDN & Brand Configuration CDN & Brand Configuration -- [#cdn--brand-configuration---] The starti.app SDK is served from a CDN at `cdn.starti.app`. Each brand gets its own URL namespace containing a customized bundle with brand-specific configuration baked in. This page explains how the distribution model works and what the brand name represents. The CDN URL pattern [#the-cdn-url-pattern] ``` https://cdn.starti.app/c/{BRAND_NAME}/main.js https://cdn.starti.app/c/{BRAND_NAME}/main.css ``` `{BRAND_NAME}` is the **brand identifier** you set up in the [starti.app Manager](https://manager.starti.app). It is a unique string that identifies your app and its configuration. When you load the SDK from this URL, you receive a bundle that includes: * The core starti.app SDK (the same for all brands) * Your brand-specific configuration (colors, domains, modules, features) * Any enabled factory modules (smart banners, intro flows, push notification buttons, etc.) This means the SDK is ready to use with your settings as soon as the script loads — no additional configuration API calls are needed. How builds work [#how-builds-work] When you update your brand configuration in the [starti.app Manager](https://manager.starti.app), the factory rebuilds your brand bundle and uploads it to the CDN. The build process: 1. Fetches the latest SDK version from a central repository 2. Reads your brand configuration from the database 3. Generates a brand-specific bundle combining the SDK with your configuration 4. Uploads the result to `cdn.starti.app/c/{BRAND_NAME}/` This happens automatically — you do not need to trigger builds manually. Brand registration [#brand-registration] Brands are created and managed in the [starti.app Manager](https://manager.starti.app). When you create a brand, you configure: * **Brand name / ID** — The unique identifier used in CDN URLs * **App URL** — The web application URL your native app loads * **Internal domains** — Domains that stay inside the app's webview * **Theme** — Primary and secondary colors * **Modules** — Optional features like smart banners, intro flows, cookie clickers * **Store configuration** — App Store and Google Play IDs The brand ID is the key that ties your CDN bundle, your native app, and your manager configuration together. In production CDN bundles are cached for up to 10 minutes. After you update your configuration in the Manager, changes propagate once the cache expires. Once a new version is published, the cache is disabled for the rest of the day. Content Security Policy (CSP) [#content-security-policy-csp] If your web application uses a Content Security Policy, you need to allow the starti.app CDN domain. Add these directives to your CSP header or `` tag: ``` script-src https://cdn.starti.app; style-src https://cdn.starti.app; connect-src https://api.starti.app; ``` Full CSP example [#full-csp-example] ```html ``` CSP directives summary [#csp-directives-summary] | Directive | Required value | Reason | | ------------- | ------------------------ | -------------------------------------------------------------- | | `script-src` | `https://cdn.starti.app` | SDK JavaScript bundle | | `style-src` | `https://cdn.starti.app` | SDK stylesheet | | `connect-src` | `https://api.starti.app` | SDK API calls (push notifications, analytics, etc.) | | `img-src` | `data: blob:` | Captured images (camera, file inputs) displayed as object URLs | If you do **not** use a CSP header, no action is needed — the SDK works without one. TypeScript types [#typescript-types] The CDN bundle does not include TypeScript types. If you use TypeScript, install the `starti.app` npm package for type definitions. See [TypeScript Support](/sdk/explanation/typescript-support) for details. # Domain Handling Domain Handling [#domain-handling] When your web app runs inside the starti.app container, every link the user taps needs to go somewhere. Some links should stay inside the main webview (internal), and some should open in an in-app browser (external). This page explains how the SDK decides which is which and how you can control it. The default behavior [#the-default-behavior] By default, only your app's starting domain is considered internal. Any link to an unknown domain opens in the in-app browser — a browser view that appears on top of your app, which the user can close to return. This prevents the user from accidentally navigating away from your content. ``` your-app.com/page → stays in the main webview (internal) maps.google.com → opens in the in-app browser (external) partner-site.com → opens in the in-app browser (external) ``` Internal domains [#internal-domains] Internal domains are loaded inside the app's webview. The user stays in the app and sees the content inline. Use this for domains that are part of your app experience, like a partner site or a payment gateway. ```typescript await startiapp.App.addInternalDomain("partner-site.com"); // Now partner-site.com loads inside the app ``` Internal domains are matched as exact strings against the host of the URL. External domains [#external-domains] External domains use **regex patterns** for flexible matching. This is useful when you want to force certain link patterns to always open in the in-app browser, even if they would otherwise match an internal domain. ```typescript await startiapp.App.addExternalDomains( /maps\.google\.com/, /\.pdf$/ ); ``` The SDK serializes the `RegExp` as `{ pattern, flags }` and sends it to the native layer, which applies the pattern against the full URL. External domains take **precedence** over internal domains. If a URL matches both an internal domain and an external pattern, it opens in the in-app browser. The "all internal" mode [#the-all-internal-mode] Sometimes you want everything to stay in the app. For example, if your app is a browser-like experience or aggregates content from many sources: ```typescript await startiapp.App.handleAllDomainsInternally(); ``` After this call, all domains are treated as internal. You can still force specific patterns to be external: ```typescript await startiapp.App.handleAllDomainsInternally(); await startiapp.App.addExternalDomains(/play\.google\.com/, /apps\.apple\.com/); ``` `handleAllDomainsInternally()` is a **session-only** setting. It resets when the app restarts. External domain rules persist normally. To go back to the default behavior: ```typescript await startiapp.App.restoreDefaultDomainHandling(); ``` Decision flowchart [#decision-flowchart] The navigatingPage event [#the-navigatingpage-event] Whenever the app navigates, the `navigatingPage` event fires with the target URL and whether it will open externally: ```typescript startiapp.App.addEventListener("navigatingPage", (event) => { console.log("Going to:", event.detail.url); console.log("External?", event.detail.opensExternalbrowser); }); ``` This is useful for analytics, logging, or dynamically adjusting behavior based on where the user is going. Opening a URL in the device's browser [#opening-a-url-in-the-devices-browser] If you need to open a URL in the device's actual browser (Safari, Chrome) — completely outside the app — use `openExternalBrowser`. This is the only way to open Safari or Chrome; external domains always open in the in-app browser, not the device's browser. ```typescript await startiapp.App.openExternalBrowser("https://maps.google.com/?q=Copenhagen"); ``` Practical patterns [#practical-patterns] Allow a payment gateway in-app [#allow-a-payment-gateway-in-app] ```typescript await startiapp.App.addInternalDomain("checkout.stripe.com"); // User completes payment without leaving the app // Remove when done: await startiapp.App.removeInternalDomain("checkout.stripe.com"); ``` Force store links to the in-app browser [#force-store-links-to-the-in-app-browser] ```typescript await startiapp.App.addExternalDomains( /apps\.apple\.com/, /play\.google\.com/ ); ``` Allow everything except downloads [#allow-everything-except-downloads] ```typescript await startiapp.App.handleAllDomainsInternally(); await startiapp.App.addExternalDomains(/\.pdf$/, /\.zip$/, /\.xlsx$/); ``` # TypeScript Support TypeScript Support [#typescript-support] The starti.app SDK is written in TypeScript and ships with full type definitions. You can get autocompletion, type checking, and inline documentation in any TypeScript-aware editor. Setup [#setup] Install the types package [#install-the-types-package] Install the `starti.app` npm package as a dev dependency. This provides the type definitions — the actual SDK is still loaded from the CDN at runtime. ```bash npm install --save-dev starti.app ``` Load the SDK from CDN [#load-the-sdk-from-cdn] The SDK itself is loaded via a script tag, not imported as a module. Add it to your HTML `` as usual: ```html ``` 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. 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 always 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 `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) for the full list of options. 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.

``` 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 **all** web data the app has accumulated, including cookies, localStorage, cache, and session storage. ```javascript await startiapp.Storage.clearWebData(); console.log("All web data has been cleared."); ``` `clearWebData()` is a destructive operation. The user will be logged out of all web sessions, all cookies will be deleted, and all cached data will be gone. Use this for "reset app" or "clear cache" features — not for routine storage management. 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. 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 ``` Use `darkContent: "auto"` to let the app choose the content colour from the safe area background colour's brightness: ```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(); ``` 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: [], }, }); ``` 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. # 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 (internal) or open them in an in-app browser (external). 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 open 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. 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 and open in the in-app browser: ```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 force specific ones to open externally 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] If you need to open a URL in the device's actual browser (Safari, Chrome) — completely outside the app — use `openExternalBrowser()`. This is different from external domains, which open in the in-app browser. ```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. # 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 # 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) # 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) # 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"); } ``` *** 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. External domains 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 are treated as external. **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 safe area background colour's brightness. **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). **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"] ``` *** 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. }); ``` *** Types [#types] NavigatingPageEvent [#navigatingpageevent] ```typescript interface NavigatingPageEvent { url: string; opensExternalbrowser: boolean; } ``` VibrationIntensity [#vibrationintensity] ```typescript enum VibrationIntensity { Low = 0, Medium = 1, High = 2, Intense = 3, } ``` SetStatusBarOptions [#setstatusbaroptions] ```typescript type SetStatusBarOptions = SafeAreaSideOptions & { hideText: boolean; // true = dark content (for light backgrounds) // false = light content (for dark backgrounds) // "auto" = chosen from the safe area background colour's brightness darkContent: boolean | "auto"; advancedSafeAreaOptions?: AdvancedSafeAreaOptions; }; ``` SafeAreaSideOptions [#safeareasideoptions] ```typescript type SafeAreaSideOptions = | { removeSafeArea: false; safeAreaBackgroundColor: string } | { removeSafeArea: true }; ``` 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; } ``` 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; }; ``` # 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. | # 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"); } ``` *** 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); ``` *** 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"]); ``` 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 }` —The notification content. **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(); } ``` # 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 all web data stored by the app, including cookies, `localStorage`, and cache. **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; } ``` # 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. # 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. ```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 or topics. The request body is an array of notification objects, allowing you to send multiple notifications in a single request. Each notification must target either `userIds` or `topics`, not both. The `userIds` correspond to the IDs registered via [`startiapp.User.registerId()`](/sdk/reference/user#registerid-userid-promisevoid) on the client side. 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 } ] ``` Either `userIds` 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 '[ { "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 ``` Responses [#responses] An empty response indicates all notifications were sent successfully. If there are warnings (for example, a user ID that was not found), the response includes them: ```json { "message": "Notifications sent with warnings", "warnings": [ "User 'user789' not found" ] } ``` 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" } ] ] } ``` Returned when the API key is missing or invalid. # Se app performance i Dashboard Sådan bruger du Dashboard [#sådan-bruger-du-dashboard] Hvad er Dashboard? [#hvad-er-dashboard] Dashboard er forsiden i starti.app Manager og giver dig et samlet overblik over, hvordan din app klarer sig. Her kan du følge udviklingen i downloads og aktive brugere over tid — og se, om dine push notifikationer gør en forskel. Du behøver ikke gøre andet end at [åbne siden](https://manager.starti.app) for at se dataen. Alle tre grafer opdateres automatisk, når du logger ind. De tre grafer [#de-tre-grafer] Aktive brugere [#aktive-brugere] Denne graf viser, hvor mange brugere der er aktive i din app over den valgte tidsperiode. Du kan se to kurver: * **Samlet antal brugere** — det samlede antal brugere, der har åbnet appen i perioden * **Nye brugere** — antallet af brugere, der har åbnet appen for første gang i perioden Downloads [#downloads] Denne graf viser, hvor mange nye downloads appen har fået pr. dag eller pr. måned. Et nyt download svarer til en ny bruger, der åbner appen for første gang. Brug den til at vurdere, om appen vokser, og til at se, om specifikke aktiviteter — fx kampagner, events eller nyhedsbreve — har ført til en stigning i downloads. Push-effekt [#push-effekt] Push-effekt-grafen er kun tilgængelig, når du viser data pr. dag. Den kombinerer to typer information i samme graf: * **Den blå kurve** viser antallet af aktive brugere pr. dag * **De stiplede linjer** markerer de dage, hvor du har sendt en push notifikation til kategorier eller til alle brugere af appen Grafen giver dig et hurtigt visuelt svar på, om dine push notifikationer fører til øget aktivitet i appen. Vælg tidsperiode [#vælg-tidsperiode] Øverst til højre på siden kan du skifte mellem to visninger: * **Dag** — viser daglige tal for de seneste 365 dage. Push-effekt-grafen er tilgængelig i denne visning. * **Måned** — viser månedlige tal for de seneste 12 måneder. Push-effekt-grafen vises ikke her. Klik på **Dag** eller **Måned** for at skifte mellem de to visninger. Dit valg huskes næste gang du åbner [Dashboard](https://manager.starti.app). Opdater data [#opdater-data] Klik på **opdatér-ikonet** øverst til højre for manuelt at hente de nyeste data ind i graferne. *** Hold øje med Push-effekt-grafen på de dage, du sender push notifikationer. En tydelig stigning i aktive brugere samme dag eller dagen efter er et godt tegn på, at din push notifikation virker. Se også [#se-også] Lær at sende push notifikationer og mål effekten i Dashboard Del et direkte link til din app, der virker på alle platforme # Få flere downloads med Flowlink Sådan bruger du Flowlink [#sådan-bruger-du-flowlink] Hvad er Flowlink? [#hvad-er-flowlink] Flowlink er din apps universelle delingslink. Det er ét enkelt link, der automatisk sender brugeren det rigtige sted hen — uanset om de åbner det på en iPhone, en Android-telefon eller en computer: * **iPhone** — brugeren sendes direkte til din app i App Store * **Android** — brugeren sendes direkte til din app i Google Play * **Computer (PC/Mac)** — brugeren ser en QR-kode, de kan scanne med deres telefon for at blive sendt til den rigtige app store Dit Flowlink ser sådan ud: ``` https://link.starti.app/[dit-brand] ``` Hvor finder du dit Flowlink? [#hvor-finder-du-dit-flowlink] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Gå til Flowlink [#gå-til-flowlink] Gå til **[Flowlink](https://manager.starti.app/flowlink)** i menubaren i venstre side under **Opsætning**. Find dit link og din QR-kode [#find-dit-link-og-din-qr-kode] På siden finder du to ting: * **Dit Flowlink** — kopiér linket ved at klikke på kopier-ikonet, eller åbn det direkte i browseren ved at klikke på pil-ikonet * **QR-koden** — se afsnittet nedenfor for at lære, hvordan du downloader og bruger den Sådan downloader du QR-koden [#sådan-downloader-du-qr-koden] QR-koden genereres automatisk ud fra dit Flowlink og er klar til brug med det samme. Den kan bruges på alt fra roll-up bannere og plakater til visitkort og andet trykt materiale. Sådan downloader du QR-koden: 1. Højreklik på QR-koden på [Flowlink-siden](https://manager.starti.app/flowlink) i starti.app Manager 2. Vælg **"Gem billede som..."** fra menuen 3. Gem billedet på din computer QR-koden gemmes som en SVG-fil, der kan skaleres til enhver størrelse uden at miste kvalitet — perfekt til tryk. Husk at teste QR-koden, inden du sender den til tryk. Scan den med din egen telefon og tjek, at du lander på den rigtige side. *** Hvornår er Flowlink nyttigt? [#hvornår-er-flowlink-nyttigt] Flowlink er ideel, når du vil dele et link til appen, men ikke ved, om modtageren bruger en iPhone eller Android. I stedet for at have to separate links — ét til App Store og ét til Google Play — kan du altid bruge det ene Flowlink. **Eksempler på brug:** * Link i nyhedsbreve og e-mails, der opfordrer brugere til at downloade appen * QR-kode på roll-up bannere, plakater eller trykt materiale til events og messer * Link på din hjemmeside * QR-kode på produkter, emballage eller i butikker * Link i opslag på sociale medier Se også [#se-også] Bring brugerne tilbage til appen med push notifikationer # Tilføj Smart Banner på din hjemmeside Tilføj Smart Banner på din hjemmeside [#tilføj-smart-banner-på-din-hjemmeside] Hvad er et Smart Banner? [#hvad-er-et-smart-banner] Et Smart Banner er en lille bjælke, der vises øverst på din hjemmeside, når nogen besøger den på en mobiltelefon. Banneret viser dit app-ikon, app-navn og en knap — fx "Åbn" — der sender brugeren direkte til din app eller til App Store/Google Play, hvis de ikke har den installeret endnu. Det er en enkel og effektiv måde at øge antallet af downloads på, fordi du når folk, der allerede besøger din hjemmeside. *** Sådan sætter du Smart Banneret op [#sådan-sætter-du-smart-banneret-op] Aktivér banneret [#aktivér-banneret] Gå til **[Smart Banner](https://manager.starti.app/smart-banner)** i menubaren i venstre side. Slå **Vis smart banner** til med kontakten øverst på siden. Resten af indstillingerne bliver tilgængelige, når du slår banneret til. Vælg bannerstil [#vælg-bannerstil] Under **Generelt** kan du vælge mellem to stile: Den klassiske bannerstil med en luk-knap i venstre side og afrundede hjørner. Brugeren kan lukke banneret, hvis de ikke ønsker at downloade appen. En kompakt version uden luk-knap. Passer godt ind på hjemmesider med et minimalistisk udtryk. Tilføj detaljer (valgfrit) [#tilføj-detaljer-valgfrit] I feltet **Detaljer** kan du skrive en kort tekst, der vises under app-navnet i banneret — fx "Gratis · Åbn direkte i appen" eller en kort beskrivelse af appen. Feltet er valgfrit. Lad det stå tomt, hvis du kun vil vise app-navn og knap. Tilpas udseendet (valgfrit) [#tilpas-udseendet-valgfrit] Under **Udseende** kan du tilpasse banneret, så det matcher dit brands farver: * **Baggrundsfarve** — farven bag hele banneret * **Tekstfarve** — farven på app-navn og detaljetekst * **Knapfarve** — baggrundfarven på knappen * **Knaptekstfarve** — farven på teksten i knappen * **Knaptekst** — teksten på knappen, fx "Åbn" eller "Download" Klik på farvefeltet for at åbne farvevælgeren. Du kan enten vælge en farve visuelt eller skrive en farvenøgle direkte (fx `#007aff`). Begræns til bestemte domæner (valgfrit) [#begræns-til-bestemte-domæner-valgfrit] Under **Domænemålretning** kan du vælge, at banneret kun skal vises på bestemte sider af din hjemmeside. Skriv et domæne — fx `example.com` eller `shop.example.com` — og tryk Enter eller klik **+** for at tilføje det. Du kan tilføje flere domæner. Lader du feltet stå tomt, vises banneret på alle sider, der bruger din app-integration. Udgiv til Web [#udgiv-til-web] Klik på knappen **Udgiv til Web** øverst på siden for at gøre banneret synligt for dine besøgende. Vær opmærksom på, at der kan gå 10-15 minutter før dit Smart Banner bliver synligt på hjemmesiden. Dine ændringer er ikke synlige på hjemmesiden, før du har klikket **Udgiv til Web**. Husk dette trin, inden du lukker siden. *** Se også [#se-også] Del ét link til din app, der virker på iPhone, Android og PC Følg med i, hvordan din app klarer sig med nøgletal og statistik # Opret og administrer API nøgler Opret og administrer API nøgler [#opret-og-administrer-api-nøgler] Hvad er API nøgler? [#hvad-er-api-nøgler] API nøgler giver dig mulighed for at sende push notifikationer programmatisk — fx fra dit eget system, en webshop eller et automatiseringsværktøj — uden at du manuelt skal logge ind i starti.app Manager. Hver API nøgle er knyttet til dit brand og har tilladelse til at sende push notifikationer via starti.app's API. En API nøgle vises kun én gang, når den oprettes. Sørg for at gemme den et sikkert sted med det samme — du kan ikke hente den igen bagefter. Opret en ny API nøgle [#opret-en-ny-api-nøgle] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn API nøgler [#åbn-api-nøgler] Gå til **[API nøgler](https://manager.starti.app/api-keys)** i menubaren i venstre side. Start oprettelse [#start-oprettelse] Klik på **Opret ny nøgle** øverst til højre på siden. Giv nøglen et navn [#giv-nøglen-et-navn] Skriv et beskrivende navn til nøglen i feltet **Navn** — fx "Webshop integration" eller "Newsletter automation". Navnet hjælper dig med at holde styr på, hvilke systemer der bruger hvilken nøgle. Gem nøglen sikkert [#gem-nøglen-sikkert] Klik på **Opret nøgle**. Din nye API nøgle vises nu i en boks på skærmen. Klik på kopier-ikonet og gem nøglen et sikkert sted — fx i din adgangskodemanager eller som en miljøvariabel i dit system. Klik herefter på **Jeg har gemt nøglen** for at lukke dialogen. Nøglen vises kun denne ene gang. Har du ikke gemt den, skal du slette nøglen og oprette en ny. *** Administrer dine API nøgler [#administrer-dine-api-nøgler] Under **Aktive API nøgler** kan du se en oversigt over alle nøgler, der er oprettet til dit brand. For hver nøgle vises: * **Navn** — det navn, du gav nøglen ved oprettelsen * **Rettigheder** — hvad nøglen har adgang til (pt. `PushNotifications/Send`) * **Oprettet** — hvornår nøglen blev oprettet * **Oprettet af** — hvilken bruger der oprettede nøglen * **Sidst brugt** — hvornår nøglen sidst blev brugt til at kalde API'et * **Forbrug** — hvor mange gange nøglen har været brugt Slet en API nøgle [#slet-en-api-nøgle] Klik på skraldespandikonet ud for den nøgle, du vil slette, og bekræft sletningen i den dialog, der vises. Sletning af en API nøgle kan ikke fortrydes. Alle systemer, der bruger nøglen, vil øjeblikkeligt miste adgang og holde op med at fungere. Sørg for at opdatere dine systemer, inden du sletter en nøgle. *** Opret separate nøgler til hvert af dine systemer eller integrationer. Så kan du nemt tilbagekalde adgangen for ét system uden at påvirke de andre. Se også [#se-også] Lær at sende push notifikationer manuelt fra starti.app Manager Tilføj HTML-snippetten til din hjemmeside for at integrere appen # Download testversion af app Download testversion af app [#download-testversion-af-app] Hvad er denne side til? [#hvad-er-denne-side-til] På siden [Testadgang](https://manager.starti.app/test-access) i starti.app Manager finder du links til at downloade **testversionen** af din app — både på iOS og Android. Testversionen er en helt anden app end den, dine brugere kan downloade, og den bruges, når du vil tjekke, at opdateringer i appen ser korrekte ud og virker, inden ændringer når frem til dine almindelige brugere. Testversionen bruges internt til at teste i den indledende udviklingsfase, og når den rigtige app (den vi kalder for Produktions-appen) er ude hos brugerne, bruges testversionen primært af udviklere, når de laver ændringer i appen. Du kan også styre, hvem der har adgang til at downloade testversionen, ved at tilføje og fjerne testere. Testversionen er forskellig fra produktionsversionen, der ligger i App Store og Google Play. Ændringer du tester her, er ikke synlige for almindelige brugere, før du har publiceret dem. Vil du teste push notifikationer eller Introflow? Det gøres ikke her, men via [Testmobiler](/da/academy/manager/getting-started/test-devices) — der er en separat guide til det. *** iOS — TestFlight [#ios--testflight] Apples testdistributionstjeneste hedder **TestFlight**. På iOS-kortet finder du: * **Et link til testversionen** — kopiér det med kopieringsknappen, eller scan QR-koden med en mobil for at åbne det direkte * **En liste over testere** — personer der har fået adgang til testversionen Sådan får iOS-testere adgang [#sådan-får-ios-testere-adgang] Der er to måder at give folk adgang til iOS-testversionen: Hvis appen er godkendt af Apple til ekstern test, kan alle med TestFlight-linket downloade testversionen uden at blive tilføjet på forhånd. Ulempen er, at Apple gennemgår hver ny version, inden den bliver tilgængelig — det tager typisk et par arbejdsdage. Du kan tilføje testere manuelt ved at indtaste deres navn og e-mailadresse. De tilføjes derefter i App Store Connect og får adgang til nye versioner med det samme — uden at afvente Apples gennemgang. Processen er: 1. Testeren modtager en invitation fra App Store Connect og **accepterer den** via linket i e-mailen 2. Testeren modtager en ny mail med et link til at installere appen TestFlight på deres iPhone 3. Testeren **åbner TestFlight** og downloader testversionen E-mailadressen skal være den, testeren er logget ind med på deres iPhone (deres Apple ID). Tilføj en iOS-tester [#tilføj-en-ios-tester] 1. Gå til **[Testadgang](https://manager.starti.app/test-access)** i menubaren i venstre side 2. Find **iOS**-kortet 3. Indtast testerens **navn** og **e-mailadresse** 4. Klik på **Tilføj** Efter du har tilføjet en tester, går der typisk cirka 15 minutter, før vedkommende kan downloade appen. Mens opgaven behandles, vises et gult ur-ikon ud for testerens navn. *** Android — Google Play [#android--google-play] På Android-kortet finder du: * **Et link til testversionen** — kopiér det med kopieringsknappen, eller scan QR-koden med en mobil for at åbne det direkte * **En liste over testere** — personer der har fået adgang til testversionen Sådan får Android-testere adgang [#sådan-får-android-testere-adgang] På Android skal testere tilmeldes via e-mail, inden linket til Google Play virker for dem. Når de er tilmeldt, kan de downloade testversionen direkte via linket eller ved at scanne QR-koden. E-mailadressen skal være den, testeren er logget ind med på deres Android-enhed (deres Google-konto). Tilføj en Android-tester [#tilføj-en-android-tester] 1. Gå til **[Testadgang](https://manager.starti.app/test-access)** i menubaren i venstre side 2. Find **Android**-kortet 3. Indtast testerens **navn** og **e-mailadresse** 4. Klik på **Tilføj** Efter du har tilføjet en tester, går der typisk under én arbejdsdag, før vedkommende kan downloade appen. Mens opgaven behandles, vises et gult ur-ikon ud for testerens navn. *** Det gule ur-ikon — hvad betyder det? [#det-gule-ur-ikon--hvad-betyder-det] Et gult ur-ikon ud for en testers navn betyder, at testeren afventer godkendelse. Testeren skal være godkendt før han/hun kan downloade testversionen. Godkendelsen sker automatisk, og du kan desværre ikke gøre noget for at fremskynde processen. iOS brugere bliver typisk godkendt indenfor 15 minutter, imens der kan gå op til en arbejdsdag for Android brugere. *** Se også [#se-også] Del ét link til din app, der virker på iPhone, Android og PC Tilføj en enhed som testmobil, så du kan teste push notifikationer og Introflow # Integrer din app med din hjemmeside Integrer din app med din hjemmeside [#integrer-din-app-med-din-hjemmeside] Hvad er hjemmesideopsætning? [#hvad-er-hjemmesideopsætning] Hjemmesideopsætning giver dig det HTML-kodestykke (snippet), du skal indsætte på din hjemmeside for at integrere din starti.app-app med dit site. Snippetten er unik for dit brand og genereres automatisk — du skal blot kopiere den og sætte den ind på din hjemmeside. Integrationen gør det muligt for din hjemmeside og din app at arbejde tæt sammen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til hjemmesidens kildekode eller et CMS, der giver dig mulighed for at redigere i HEAD-elementet. Hvis du er i tvivl, kan du kontakte den, der administrerer din hjemmeside. Ring eller skriv til os, hvis snippetten ikke vises i starti.app Manager — det kan betyde, at den endnu ikke er genereret for dit brand. Sådan finder og kopierer du din snippet [#sådan-finder-og-kopierer-du-din-snippet] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Hjemmesideopsætning [#åbn-hjemmesideopsætning] Gå til **[Hjemmesideopsætning](https://manager.starti.app/website-setup)** i menubaren i venstre side. Kopiér HTML-snippetten [#kopiér-html-snippetten] Under **HTML snippet** vises de linjer, der skal tilføjes til din hjemmeside. Klik på **Kopier** for at kopiere hele snippetten til udklipsholderen. Hvis der ikke vises nogen snippet, er den endnu ikke genereret for dit brand. Kontakt starti.app, så sørger vi for at få den klar til dig. Indsæt snippetten på din hjemmeside [#indsæt-snippetten-på-din-hjemmeside] Sæt det kopierede kodestykke ind i ``-elementet på din hjemmeside — gerne på alle sider, medmindre du kun ønsker integrationen på bestemte undersider. Ændringen skal gemmes og udgives på hjemmesiden, før integrationen er aktiv. *** Snippetten er unik for dit brand. Brug altid den snippet, der vises under dit eget brand i starti.app Manager — kopier den ikke fra et andet brand. Hvis din hjemmeside er bygget i et CMS som WordPress, Webflow eller lignende, er der typisk et felt til custom code i ``-sektionen under tema- eller sideindstillingerne. Det er her, du skal indsætte snippetten. Se også [#se-også] Opret API nøgler til at sende push notifikationer programmatisk # Tilføj brugere til starti.app Manager Sådan tilføjer du brugere til starti.app Manager [#sådan-tilføjer-du-brugere-til-startiapp-manager] Hvad er Manager-brugere? [#hvad-er-manager-brugere] Manager-brugere er de personer, der har adgang til at opsætte og administrere din app via starti.app Manager. Det kan fx være kolleger, der skal hjælpe med at sende push notifikationer eller opdatere indhold i appen. Brugere tilføjes med deres e-mailadresse og logger ind med Microsoft, Google eller Apple — præcis som du selv gør. Vær opmærksom på, at når du tilføjer brugere i starti.app Manager, får de direkte adgang til at ændre indhold i din app. Tilføj derfor KUN brugere, du har tillid til. Tilføj en ny bruger [#tilføj-en-ny-bruger] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Brugere [#åbn-brugere] Vælg **[Brugere](https://manager.starti.app/users)** i menubaren i venstre side. Tryk på "Tilføj bruger" [#tryk-på-tilføj-bruger] Tryk på knappen **Tilføj bruger** i øverste højre hjørne af oversigten. Udfyld e-mailadressen [#udfyld-e-mailadressen] Skriv e-mailadressen på den person, du vil give adgang, i feltet **Email**. Tryk herefter på **Opret bruger** for at gemme. Brugeren vises nu i oversigten under **Manager-brugere** og kan logge ind i starti.app Manager med den registrerede e-mailadresse. Den nye bruger modtager ikke automatisk en invitationsmail. Sørg for selv at give personen besked om, at de nu har adgang, og at de skal logge ind på [manager.starti.app](https://manager.starti.app/login). *** Administrer dine brugere [#administrer-dine-brugere] Gå til **[Brugere](https://manager.starti.app/users)** i menuen for at se og redigere eksisterende Manager-brugere: * **Fjern** en bruger ved at klikke på skraldespandikonet ud for brugeren Sletning af en bruger fjerner vedkommendes adgang til Manager med det samme. Handlingen kan ikke fortrydes — men du kan altid tilføje brugeren igen. *** Se også [#se-også] Kom i gang med at opsætte det første, nye brugere møder i appen Sådan sender du en push notifikation til dine brugere # Opdater App Store / Google Play Sådan udfylder du App Store / Google Play-oplysninger [#sådan-udfylder-du-app-store--google-play-oplysninger] Hvad kan du på App Store / Google Play-siden? [#hvad-kan-du-på-app-store--google-play-siden] På denne side i starti.app Manager udfylder du de tekstoplysninger, der vises om din app i App Store (iOS) og Google Play (Android). Det er de oplysninger, potentielle brugere ser, når de finder din app i de to app stores. Siden er opdelt i fire sektioner: **App informationer**, **Kontaktoplysninger**, **Privatlivspolitik** og **D-U-N-S nummer**. Det er starti.app, der uploader og opdaterer din app i App Store og Google Play. Du kan altså ikke selv udgive opdateringer direkte — men ved at holde denne side opdateret sørger du for, at vi altid har de korrekte tekster klar til næste udgivelse. *** App informationer [#app-informationer] Her udfylder du de tekster, der beskriver din app i de to app stores. Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn App Store / Google Play [#åbn-app-store--google-play] Vælg **[App Store / Google Play](https://manager.starti.app/app-store-settings)** i menubaren i venstre side. Udfyld App-navn [#udfyld-app-navn] Skriv navnet på din app i feltet **App-navn**. App-navnet vises under appens ikon i App Store og Google Play og må maksimalt være **20 tegn**. Udfyld Undertitel [#udfyld-undertitel] Skriv en kort undertitel i feltet **Undertitel**. Undertitlen vises kun i App Store (ikke Google Play) og må maksimalt være **30 tegn**. Udfyld Kort beskrivelse [#udfyld-kort-beskrivelse] Skriv en kort beskrivelse af appen i feltet **Kort beskrivelse**. Den korte beskrivelse vises kun i Google Play (ikke App Store) og må maksimalt være **80 tegn**. Udfyld Beskrivelse [#udfyld-beskrivelse] Skriv en fyldig beskrivelse af din app i feltet **Beskrivelse**. Beskrivelsen vises i begge app stores, når brugere klikker ind på appens side. Den må maksimalt være **4.000 tegn**. Brug beskrivelsen til at fortælle, hvad appen kan, og hvem den er til. Skriv de vigtigste informationer først — mange brugere læser kun de første linjer. Tilføj søgeord [#tilføj-søgeord] Tilføj relevante søgeord i feltet **Søgeord**. Søgeord bruges kun i App Store (ikke Google Play) til at hjælpe brugere med at finde din app via søgning. Det samlede antal tegn på tværs af alle søgeord må maksimalt være **100 tegn**. Vælg søgeord, som potentielle brugere sandsynligvis vil søge på — og som ikke allerede indgår i app-navnet eller undertitlen, da App Store automatisk inkluderer disse. Alle felter gemmes automatisk, når du klikker videre til et nyt felt. *** Kontaktoplysninger [#kontaktoplysninger] Her udfylder du de kontaktoplysninger, som vises i App Store og Google Play, så brugere kan kontakte jer, hvis de har spørgsmål. Udfyld felterne **E-mail**, **Telefon** og **Website** med jeres foretrukne kontaktoplysninger. *** Privatlivspolitik [#privatlivspolitik] App Store og Google Play kræver, at alle apps har en privatlivspolitik med information om, hvordan brugerdata behandles. Indsæt URL'en til jeres privatlivspolitik i feltet **URL** under **Privatlivspolitik**. Privatlivspolitikken er et krav fra begge app stores. Uden en gyldig URL kan vi ikke udgive eller opdatere appen. *** D-U-N-S nummer [#d-u-n-s-nummer] D-U-N-S nummeret er et unikt identifikationsnummer for jeres virksomhed og bruges af Apple til at verificere jer som app-udgiver i App Store. Indsæt jeres D-U-N-S nummer i feltet **D-U-N-S**. Har I ikke et D-U-N-S nummer endnu, kan I ansøge om et gratis via [Dun & Bradstreet](https://www.dnb.com/duns-number/get-a-duns.html). Det kan tage op til 30 dage at modtage et nyt nummer. D-U-N-S nummeret bruges kun til App Store og er ikke relevant for Google Play. *** Oversæt til andre sprog [#oversæt-til-andre-sprog] Hvis du har din app på flere forskellige sprog, skal du oversætte alle dine tekster til de ønskede sprog. Det gør du lige under titlen **App informationer**, hvor du trykker på det ønskede sprog. Du kan enten vælge at oversætte et enkelt felt ad gangen ved at trykke på globussen ud for feltet, eller du kan trykke på knappen "Oversæt alle manglende" for at oversætte alle felter til det pågældende sprog. *** Se også [#se-også] Giv kolleger adgang til at administrere appen i Manager Sådan sender du en push notifikation til dine brugere # Opsæt Introflow Sådan opsætter du dit Introflow [#sådan-opsætter-du-dit-introflow] Hvad er et Introflow? [#hvad-er-et-introflow] Introflowet er det første, nye brugere møder, når de åbner appen for første gang. Det er her, du introducerer appen, og det er her, brugerne bliver bedt om at acceptere push notifikationer og privatlivspolitik. Et godt Introflow øger sandsynligheden for, at brugerne siger ja til push notifikationer — og det er afgørende for, at du efterfølgende kan nå ud til dem med relevante beskeder. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Step by step guide til at opsætte dit Introflow [#step-by-step-guide-til-at-opsætte-dit-introflow] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Introflow [#åbn-introflow] Vælg **[Introflow](https://manager.starti.app/introflow)** i menubaren i venstre side. Tilføj en side [#tilføj-en-side] Tryk på den side-type, du gerne vil tilføje som det første trin i dit Introflow. Du kan vælge mellem fem typer: En helt fri side, hvor du selv bestemmer indholdet. Brug den til at introducere appen, fremhæve funktioner eller give brugerne en god start — med din egen overskrift, tekst og billede. Et trin med forudindstillinger til at bede brugerne om at acceptere push notifikationer. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Vi anbefaler, at du altid tilføjer dette trin, da det giver dig mulighed for fremover at sende push notifikationer til dine brugere. Et trin med forudindstillinger til at bede brugerne om at dele deres lokation. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Et trin med forudindstillinger til at bede brugerne om at acceptere dine vilkår og privatlivspolitik. Du udfylder selv alle inputfelterne i trinnet som titel, terms, osv. Et trin med forudindstillinger til at bede brugerne om at give samtykke til cookies. Beskrivelsesteksten hentes automatisk fra din cookie-udbyder. Du udfylder selv titel og knaptekster (fx "Accepter alle", "Afvis alle" og "Gem indstillinger"). Dette trin kræver, at du har valgt en understøttet cookie-udbyder (fx Cookie Information). Udfyld felterne [#udfyld-felterne] Udfyld inputfelterne for trinnet. Det er forskelligt, hvilke inputfelter der er til hver type, men du skal som oftest udfylde: * **Titel** — en kort og fængende overskrift, der fortæller brugeren, hvad trinnet handler om * **Beskrivelse** — en kort beskrivelse, der uddyber overskriften og motiverer brugeren til at fortsætte * **Knaptekst** — teksten på knappen, der sender brugeren videre til næste trin (fx "Næste" eller "Fortsæt") Hold teksten i hvert trin kort og handlingsorienteret. Brugerne er nye i appen og skal motiveres til at komme godt i gang — ikke læse lange forklaringer. Sortér trinnene [#sortér-trinnene] Træk og slip trinnene i den ønskede rækkefølge ved hjælp af ikonet med de seks prikker i venstre side af hvert trin. Tilføj en redirect URL [#tilføj-en-redirect-url] Ønsker du, at brugerne skal lande på en bestemt side i appen efter, de har gennemført Introflowet? Så skal du indsætte den ønskede URL i feltet **Redirect efter gennemført Introflow**. Indsætter du ikke en URL i dette felt, bliver brugerne sendt til forsiden i din app. Oversæt dit Introflow [#oversæt-dit-introflow] Hvis din app findes på flere sprog, skal du huske at oversætte dit Introflow til alle valgte sprog. Du skifter sprog i toppen af Introflow siden i din Manager, og her kan du enten oversætte ét felt ad gangen ved at trykke på globus-ikonet ud for feltet. Du kan også trykke på **Oversæt alle manglende**, og så bliver alle felter for det pågældende sprog oversat. Tallene ud for hvert sprog indikerer, om der mangler oversættelser. *** Test og udgiv dit Introflow [#test-og-udgiv-dit-introflow] Inden du udgiver Introflowet, anbefaler vi, at du tester det på en testenhed. På den måde kan du sikre dig, at flowet ser korrekt ud og fungerer som forventet — både på iOS og Android. Hvis du endnu ikke har tilføjet testenheder, skal du gøre det inden du tester. Læs guiden: [Tilføj testmobil](/da/academy/manager/getting-started/test-devices) Når du har tilføjet og udfyldt alle nødvendige trin til dit Introflow, skal du teste det inden du udgiver det. Test Introflowet på følgende måde: Vælg **Udgiv til Udvikling** — denne udgiver KUN på dit interne testmiljø og når ikke ud til brugerne Åbn din app på mobilen Ryst mobilen En testside dukker op — vælg **Vis udviklings introflow** for at starte testen Tjek følgende, når du tester: * Vises billeder og tekst korrekt på skærmen? * Er rækkefølgen af trin logisk og nem at følge? * Virker knapperne? Sender de brugeren videre til det rigtige trin? Når du har testet og er tilfreds med Introflowet, kan du udgive det til produktion. Udgiv Introflowet på følgende måde: Vælg **Udgiv til Produktion** Introflowet er nu aktivt og vil blive vist for alle nye brugere, der åbner appen for første gang. Vær opmærksom på, at der kan gå 15 minutter fra du udgiver Introflowet, til det er aktivt i appen. *** Tjekliste før du udgiver dit Introflow [#tjekliste-før-du-udgiver-dit-introflow] * Er overskriften på hvert trin kort og fængende? * Er teksten på hvert trin kort, præcis og motiverende? * Er billederne i høj opløsning og relevante for indholdet? * Er knapeteksterne handlingsorienterede og tydelige? * Har du inkluderet et trin, der beder brugerne om at acceptere push notifikationer? * Er rækkefølgen af trin logisk set fra brugerens perspektiv? * Har du testet flowet på en testenhed — gerne både iOS og Android? *** Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer Sådan opretter og sender du en manuel push notifikation til dine brugere # Microsoft-login beder om godkendelse fra administrator Sådan håndterer du Microsoft-godkendelsesbeskeden [#sådan-håndterer-du-microsoft-godkendelsesbeskeden] Hvis du (eller en kollega) prøver at logge ind på **starti.app Manager** med Microsoft og ser en besked som: > **Godkendelse påkrævet** — Bed din administrator om at acceptere denne app …så er det **ikke et problem med starti.app**. Beskeden kommer fra Microsoft. Jeres Microsoft 365 / Entra ID-administrator har sat en regel op, der kræver, at de godkender nye login-apps, før nogen i virksomheden kan bruge dem. Det er jeres Microsoft (Entra ID)-opsætning, der bestemmer, om nye apps kræver admin-godkendelse. starti.app kan ikke springe det over — kun jeres IT-administrator kan godkende det. Hvad du kan gøre [#hvad-du-kan-gøre] Som almindelig bruger har du to hurtige muligheder: * **Anmod om godkendelse via Microsoft.** I selve loginvinduet kan du trykke **Anmod om godkendelse** (eller den tilsvarende knap, Microsoft viser) og skrive en kort begrundelse — fx: *"Jeg skal bruge denne app til at administrere vores firma-app."* Jeres IT-administrator modtager så en anmodning, de kan godkende. Det er typisk den hurtigste vej. * **Log ind med Google eller Apple — kun hvis du har en konto med samme e-mail.** Hvis du også har en Google- eller Apple-konto, der bruger **præcis samme e-mailadresse** som din starti.app-bruger, kan du logge ind med den i stedet og helt undgå Microsoft. * De fleste virksomheder, der bruger Microsoft 365, har ikke Google-logins til medarbejderne, så denne mulighed er ofte ikke til rådighed. * Med Apple-login må du **ikke** vælge **Skjul min e-mail** — det laver en relay-adresse, der ikke matcher din starti.app-bruger. Hvis ingen af delene virker, så send afsnittet nedenfor videre til den person, der administrerer Microsoft 365 / Entra ID hos jer. *** Til jeres IT-administrator [#til-jeres-it-administrator] Sådan godkender I starti.app Manager for organisationen i Microsoft Entra ID: Åbn Entra admin center [#åbn-entra-admin-center] Gå til [entra.microsoft.com](https://entra.microsoft.com) og log ind med en konto, der har rollen **Cloud Application Administrator** (eller højere, fx Privileged Role Administrator eller Global Administrator). Du skal også være udpeget som reviewer i admin consent-workflowet. Godkend ventende anmodninger [#godkend-ventende-anmodninger] Gå til **Entra ID → Enterprise apps**, og vælg derefter **Admin consent requests** under **Activity**. Åbn fanen **My Pending**, find anmodningen for **starti.app Manager** (den kan optræde under navnet på den underliggende OAuth-app), åbn den, og tryk **Approve**. Eller giv samtykke på forhånd [#eller-giv-samtykke-på-forhånd] Hvis der ikke ligger en ventende anmodning, kan I give samtykke på forhånd: 1. Gå til **Entra ID → Enterprise apps → All applications** 2. Søg efter og åbn **starti.app Manager** 3. Vælg **Permissions** under **Security** 4. Gennemgå de ønskede tilladelser, og tryk **Grant admin consent** starti.app Manager beder kun om basale profiloplysninger (navn, e-mail, profilbillede). Appen læser eller ændrer ikke andre data i jeres Microsoft 365-miljø. *** Hvorfor sker det? [#hvorfor-sker-det] Mange virksomheder har en indstilling i Microsoft Entra ID, der blokerer almindelige brugere fra at logge ind på nye apps uden en administrators godkendelse. Det er en sikkerhedsfunktion — ikke en begrænsning i starti.app. Når jeres IT-administrator først har godkendt starti.app Manager **én gang**, kan alle i virksomheden logge ind uden at se beskeden igen. *** Se også [#se-også] Sådan tilføjer og administrerer du brugere, der skal have adgang # Opret kategorier til push notifikationer Sådan opretter du kategorier til push notifikationer [#sådan-opretter-du-kategorier-til-push-notifikationer] Før du går i gang [#før-du-går-i-gang] Kategorier giver dig mulighed for at segmentere dine brugere, så de kun modtager push notifikationer, der er relevante for dem. Du kan fx oprette kategorier som "Nyheder", "Tilbud" og "Ordrestatus" — og lade brugerne selv vælge, hvilke de ønsker at abonnere på. Brugerne abonnerer på kategorier direkte i appen, når de accepterer push notifikationer i dit Introflow. Hvis du endnu ikke har opsat dit Introflow, skal du følge denne guide: [Opsæt Introflow](/da/academy/manager/getting-started/introflow). For den tekniske dokumentation, skal du læse guiden: [Administrer push topics i appen](/sdk/how-to/manage-push-topics). Step by step guide til at oprette en kategori [#step-by-step-guide-til-at-oprette-en-kategori] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn kategorier [#åbn-kategorier] Vælg **Push notifikationer** i menubaren i venstre side og tryk herefter på **[Kategorier](https://manager.starti.app/push-categories)**. Opret en ny kategori [#opret-en-ny-kategori] Skriv navnet på din nye kategori i inputfeltet under "Tilføj ny kategori" og tryk på **Tilføj** eller Enter. Kategorien oprettes med det samme og vises i oversigten. Når du opretter en kategori, får den automatisk tildelt et **Topic** — det er det interne navn, som en udvikler bruger til at tilføje abonnenter til kategorien og til at sende push notifikationer til dem. Topic-navnet kan **ikke ændres**, efter det er oprettet, så sørg for at kategorinavnet er korrekt fra starten. Vælg om kategorien skal vises i appen [#vælg-om-kategorien-skal-vises-i-appen] Alle nye kategorier er som standard synlige i appen. Fjern fluebenet i **Vis i app**, hvis du vil skjule kategorien for brugerne — den kan stadig bruges til afsendelse fra Manager. Vælger du at skjule en kategori i appen, er det i stedet **Topic**, du skal bruge for at sende brugere ind i kategorien. Dette skal gøres af din udvikler. *** Administrer dine kategorier [#administrer-dine-kategorier] Gå til **Push notifikationer -> [Kategorier](https://manager.starti.app/push-categories)** i menuen for at redigere eksisterende kategorier: * **Omdøb** en kategori ved at klikke på blyantsikonet ud for navnet på kategorien * **Sorter** kategorier i den ønskede rækkefølge ved at trække og slippe dem på de 6 prikker til venstre for kategorinavnet * **Slet** en kategori ved at klikke på skraldespandikonet Sletning af en kategori kan ikke fortrydes. Brugere, der abonnerer på kategorien, vil ikke længere modtage notifikationer sendt til den. *** Se også [#se-også] Sådan sender du en push notifikation til dine brugere Lad brugere abonnere og afmelde push kategorier direkte i appen # Tilføj testmobiler Sådan tilføjer du testmobiler i starti.app Manager [#sådan-tilføjer-du-testmobiler-i-startiapp-manager] Før du går i gang [#før-du-går-i-gang] Testmobiler giver dig mulighed for at sende en push notifikation eller aktivere et Introflow på udvalgte enheder, inden du sender ud til alle dine brugere. Det er en god måde at tjekke, at alt ser korrekt ud, og at links virker — på tværs af både iOS og Android. Vil du teste selve app-indholdet — fx nye funktioner eller designændringer i appen? Det gøres ikke her, men via [Download testversion af app](/da/academy/manager/development-setup/download-test-app) — der er en separat guide til det. For at tilføje en enhed som testmobil, skal du have din app installeret på enheden og sørge for, at du har den seneste opdatering til appen. Sådan tilføjer du en testmobil [#sådan-tilføjer-du-en-testmobil] Processen foregår i to dele: Først aktiverer du enheden fra selve appen, og derefter godkender du den i Manager. Åbn appen på testenheden [#åbn-appen-på-testenheden] Sørg for, at appen er åben og kører på den enhed, du vil tilføje som testmobil. Ryst mobilen [#ryst-mobilen] Ryst mobilen, mens appen er åben. Gå ud på hjemmeskærmen og åbn appen igen [#gå-ud-på-hjemmeskærmen-og-åbn-appen-igen] Gå ud til hjemmeskærmen og åbn herefter appen igen. Det er ikke nødvendigt at lukke appen helt ned. Ryst mobilen igen [#ryst-mobilen-igen] Ryst mobilen endnu en gang med appen åben. Det hele skal ske inden for 10 sekunder. Enheden vises herefter i Manager efter cirka 30 sekunder og er synlig i op til 2 minutter. Log ind i Manager [#log-ind-i-manager] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Testmobiler [#åbn-testmobiler] Vælg **[Testmobiler](https://manager.starti.app/test-devices)** i menubaren i venstre side. Find enheden under "Tilgængelige enheder" [#find-enheden-under-tilgængelige-enheder] Enheden dukker op under **Tilgængelige enheder** ca. 30 sekunder efter, du har gennemført Del 1. Den er synlig i op til 2 minutter — så du skal være klar til at godkende den. En farvet bjælke under enheden viser, hvor lang tid der er tilbage, inden den forsvinder fra listen. Tilføj enheden [#tilføj-enheden] Tryk på **Tilføj** ud for enheden. Giv enheden et navn, der gør den nem at genkende — fx *Mikkels iPhone* eller *Test Android*. Tryk herefter på **Tilføj** for at bekræfte. Enheden vises nu under **Nuværende testmobiler**. Tilføj aldrig en enhed, du ikke kender. Vi anbefaler, at du tilføjer mindst én iOS-enhed og én Android-enhed som testmobiler. Eftersom push notifikationer kan se forskelligt ud på de to platforme, giver det dig den bedste kontrol over, hvordan notifikationen ser ud inden udsendelsen. *** Administrer dine testmobiler [#administrer-dine-testmobiler] Gå til **[Testmobiler](https://manager.starti.app/test-devices)** i menuen for at se og redigere dine registrerede enheder: * **Omdøb** en enhed ved at klikke på blyantikonet ud for enhedens navn * **Fjern** en enhed ved at klikke på skraldespandikonet Fjernede testmobiler kan ikke gendannes automatisk. Du skal gennemgå registreringsprocessen igen, hvis du vil tilføje enheden på ny. *** Her kan du bruge dine testmobiler [#her-kan-du-bruge-dine-testmobiler] Test din push notifikation på udvalgte enheder, inden du sender den ud til alle Forhåndsvis og test dit Introflow på en testenhed, inden du udgiver det Test dit Velkomstflow på en testenhed, inden det går live *** Se også [#se-også] Sådan sender du en push notifikation — inkl. test til dine testmobiler Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Opdater App Store / Google Play Sådan udfylder du App Store / Google Play-oplysninger [#sådan-udfylder-du-app-store--google-play-oplysninger] Hvad kan du på App Store / Google Play-siden? [#hvad-kan-du-på-app-store--google-play-siden] På denne side i starti.app Manager udfylder du de tekstoplysninger, der vises om din app i App Store (iOS) og Google Play (Android). Det er de oplysninger, potentielle brugere ser, når de finder din app i de to app stores. Siden er opdelt i fire sektioner: **App informationer**, **Kontaktoplysninger**, **Privatlivspolitik** og **D-U-N-S nummer**. Det er starti.app, der uploader og opdaterer din app i App Store og Google Play. Du kan altså ikke selv udgive opdateringer direkte — men ved at holde denne side opdateret sørger du for, at vi altid har de korrekte tekster klar til næste udgivelse. *** App informationer [#app-informationer] Her udfylder du de tekster, der beskriver din app i de to app stores. Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn App Store / Google Play [#åbn-app-store--google-play] Vælg **[App Store / Google Play](https://manager.starti.app/app-store-settings)** i menubaren i venstre side. Udfyld App-navn [#udfyld-app-navn] Skriv navnet på din app i feltet **App-navn**. App-navnet vises under appens ikon i App Store og Google Play og må maksimalt være **20 tegn**. Udfyld Undertitel [#udfyld-undertitel] Skriv en kort undertitel i feltet **Undertitel**. Undertitlen vises kun i App Store (ikke Google Play) og må maksimalt være **30 tegn**. Udfyld Kort beskrivelse [#udfyld-kort-beskrivelse] Skriv en kort beskrivelse af appen i feltet **Kort beskrivelse**. Den korte beskrivelse vises kun i Google Play (ikke App Store) og må maksimalt være **80 tegn**. Udfyld Beskrivelse [#udfyld-beskrivelse] Skriv en fyldig beskrivelse af din app i feltet **Beskrivelse**. Beskrivelsen vises i begge app stores, når brugere klikker ind på appens side. Den må maksimalt være **4.000 tegn**. Brug beskrivelsen til at fortælle, hvad appen kan, og hvem den er til. Skriv de vigtigste informationer først — mange brugere læser kun de første linjer. Tilføj søgeord [#tilføj-søgeord] Tilføj relevante søgeord i feltet **Søgeord**. Søgeord bruges kun i App Store (ikke Google Play) til at hjælpe brugere med at finde din app via søgning. Det samlede antal tegn på tværs af alle søgeord må maksimalt være **100 tegn**. Vælg søgeord, som potentielle brugere sandsynligvis vil søge på — og som ikke allerede indgår i app-navnet eller undertitlen, da App Store automatisk inkluderer disse. Alle felter gemmes automatisk, når du klikker videre til et nyt felt. *** Kontaktoplysninger [#kontaktoplysninger] Her udfylder du de kontaktoplysninger, som vises i App Store og Google Play, så brugere kan kontakte jer, hvis de har spørgsmål. Udfyld felterne **E-mail**, **Telefon** og **Website** med jeres foretrukne kontaktoplysninger. *** Privatlivspolitik [#privatlivspolitik] App Store og Google Play kræver, at alle apps har en privatlivspolitik med information om, hvordan brugerdata behandles. Indsæt URL'en til jeres privatlivspolitik i feltet **URL** under **Privatlivspolitik**. Privatlivspolitikken er et krav fra begge app stores. Uden en gyldig URL kan vi ikke udgive eller opdatere appen. *** D-U-N-S nummer [#d-u-n-s-nummer] D-U-N-S nummeret er et unikt identifikationsnummer for jeres virksomhed og bruges af Apple til at verificere jer som app-udgiver i App Store. Indsæt jeres D-U-N-S nummer i feltet **D-U-N-S**. Har I ikke et D-U-N-S nummer endnu, kan I ansøge om et gratis via [Dun & Bradstreet](https://www.dnb.com/duns-number/get-a-duns.html). Det kan tage op til 30 dage at modtage et nyt nummer. D-U-N-S nummeret bruges kun til App Store og er ikke relevant for Google Play. *** Oversæt til andre sprog [#oversæt-til-andre-sprog] Hvis du har din app på flere forskellige sprog, skal du oversætte alle dine tekster til de ønskede sprog. Det gør du lige under titlen **App informationer**, hvor du trykker på det ønskede sprog. Du kan enten vælge at oversætte et enkelt felt ad gangen ved at trykke på globussen ud for feltet, eller du kan trykke på knappen "Oversæt alle manglende" for at oversætte alle felter til det pågældende sprog. *** Se også [#se-også] Giv kolleger adgang til at administrere appen i Manager Sådan sender du en push notifikation til dine brugere # Opsæt Introflow Sådan opsætter du dit Introflow [#sådan-opsætter-du-dit-introflow] Hvad er et Introflow? [#hvad-er-et-introflow] Introflowet er det første, nye brugere møder, når de åbner appen for første gang. Det er her, du introducerer appen, og det er her, brugerne bliver bedt om at acceptere push notifikationer og privatlivspolitik. Et godt Introflow øger sandsynligheden for, at brugerne siger ja til push notifikationer — og det er afgørende for, at du efterfølgende kan nå ud til dem med relevante beskeder. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Step by step guide til at opsætte dit Introflow [#step-by-step-guide-til-at-opsætte-dit-introflow] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Introflow [#åbn-introflow] Vælg **[Introflow](https://manager.starti.app/introflow)** i menubaren i venstre side. Tilføj en side [#tilføj-en-side] Tryk på den side-type, du gerne vil tilføje som det første trin i dit Introflow. Du kan vælge mellem fem typer: En helt fri side, hvor du selv bestemmer indholdet. Brug den til at introducere appen, fremhæve funktioner eller give brugerne en god start — med din egen overskrift, tekst og billede. Et trin med forudindstillinger til at bede brugerne om at acceptere push notifikationer. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Vi anbefaler, at du altid tilføjer dette trin, da det giver dig mulighed for fremover at sende push notifikationer til dine brugere. Et trin med forudindstillinger til at bede brugerne om at dele deres lokation. Du udfylder selv den overskrift og tekst, der vises på trinnet, inden styresystemets egen pop-up dukker op. Et trin med forudindstillinger til at bede brugerne om at acceptere dine vilkår og privatlivspolitik. Du udfylder selv alle inputfelterne i trinnet som titel, terms, osv. Et trin med forudindstillinger til at bede brugerne om at give samtykke til cookies. Beskrivelsesteksten hentes automatisk fra din cookie-udbyder. Du udfylder selv titel og knaptekster (fx "Accepter alle", "Afvis alle" og "Gem indstillinger"). Dette trin kræver, at du har valgt en understøttet cookie-udbyder (fx Cookie Information). Udfyld felterne [#udfyld-felterne] Udfyld inputfelterne for trinnet. Det er forskelligt, hvilke inputfelter der er til hver type, men du skal som oftest udfylde: * **Titel** — en kort og fængende overskrift, der fortæller brugeren, hvad trinnet handler om * **Beskrivelse** — en kort beskrivelse, der uddyber overskriften og motiverer brugeren til at fortsætte * **Knaptekst** — teksten på knappen, der sender brugeren videre til næste trin (fx "Næste" eller "Fortsæt") Hold teksten i hvert trin kort og handlingsorienteret. Brugerne er nye i appen og skal motiveres til at komme godt i gang — ikke læse lange forklaringer. Tilføj kategorier (valgfrit) [#tilføj-kategorier-valgfrit] Hvis du har oprettet kategorier til push notifikationer, kan du tilføje et trin, hvor brugerne selv vælger, hvilke kategorier de ønsker at abonnere på. Tryk på **+ Tilføj trin** og vælg **Vælg kategorier**. Hvis du endnu ikke har oprettet kategorier, skal du læse denne guide: [Opret kategorier til push notifikationer](/academy/manager/kom-godt-i-gang/push-kategorier) Sortér trinnene [#sortér-trinnene] Træk og slip trinnene i den ønskede rækkefølge ved hjælp af ikonet med de seks prikker i venstre side af hvert trin. Tilføj en redirect URL [#tilføj-en-redirect-url] Ønsker du, at brugerne skal lande på en bestemt side i appen efter, de har gennemført Introflowet? Så skal du indsætte den ønskede URL i feltet **Redirect efter gennemført Introflow**. Indsætter du ikke en URL i dette felt, bliver brugerne sendt til forsiden i din app. Oversæt dit Introflow [#oversæt-dit-introflow] Hvis din app findes på flere sprog, skal du huske at oversætte dit Introflow til alle valgte sprog. Du skifter sprog i toppen af Introflow siden i din Manager, og her kan du enten oversætte ét felt ad gangen ved at trykke på globus-ikonet ud for feltet. Du kan også trykke på **Oversæt alle manglende**, og så bliver alle felter for det pågældende sprog oversat. Tallene ud for hvert sprog indikerer, om der mangler oversættelser. *** Test og udgiv dit Introflow [#test-og-udgiv-dit-introflow] Inden du udgiver Introflowet, anbefaler vi, at du tester det på en testenhed. På den måde kan du sikre dig, at flowet ser korrekt ud og fungerer som forventet — både på iOS og Android. Hvis du endnu ikke har tilføjet testenheder, skal du gøre det inden du tester. Læs guiden: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) Når du har tilføjet og udfyldt alle nødvendige trin til dit Introflow, skal du teste det inden du udgiver det. Test Introflowet på følgende måde: Tryk på **Deploy** i øverste højre hjørne Vælg **Deploy til Udvikling** — denne udgiver KUN på dit interne testmiljø og når ikke ud til brugerne Åbn din app på mobilen Ryst mobilen En testside dukker op — vælg **Vis udviklings introflow** for at starte testen Tjek følgende, når du tester: * Vises billeder og tekst korrekt på skærmen? * Er rækkefølgen af trin logisk og nem at følge? * Virker knapperne? Sender de brugeren videre til det rigtige trin? * Fungerer kategori-valget korrekt, hvis du har tilføjet det? Når du har testet og er tilfreds med Introflowet, kan du udgive det til produktion. Udgiv Introflowet på følgende måde: Tryk på **Deploy** i øverste højre hjørne Vælg **Deploy til Produktion** Introflowet er nu aktivt og vil blive vist for alle nye brugere, der åbner appen for første gang. Vær opmærksom på, at der kan gå 15 minutter fra du udgiver Introflowet, til det er aktivt i appen. *** Tjekliste før du udgiver dit Introflow [#tjekliste-før-du-udgiver-dit-introflow] * Er overskriften på hvert trin kort og fængende? * Er teksten på hvert trin kort, præcis og motiverende? * Er billederne i høj opløsning og relevante for indholdet? * Er knapeteksterne handlingsorienterede og tydelige? * Har du inkluderet et trin, der beder brugerne om at acceptere push notifikationer? * Er rækkefølgen af trin logisk set fra brugerens perspektiv? * Har du testet flowet på en testenhed — gerne både iOS og Android? *** Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer Sådan opretter og sender du en manuel push notifikation til dine brugere # Opret kategorier til push notifikationer Sådan opretter du kategorier til push notifikationer [#sådan-opretter-du-kategorier-til-push-notifikationer] Før du går i gang [#før-du-går-i-gang] Kategorier giver dig mulighed for at segmentere dine brugere, så de kun modtager push notifikationer, der er relevante for dem. Du kan fx oprette kategorier som "Nyheder", "Tilbud" og "Ordrestatus" — og lade brugerne selv vælge, hvilke de ønsker at abonnere på. Brugerne abonnerer på kategorier direkte i appen, når de accepterer push notifikationer i dit Introflow. Hvis du endnu ikke har opsat dit Introflow, skal du følge denne guide: [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow). For den tekniske dokumentation, skal du læse guiden: [Administrer push topics i appen](/sdk/how-to/manage-push-topics). Step by step guide til at oprette en kategori [#step-by-step-guide-til-at-oprette-en-kategori] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn kategorier [#åbn-kategorier] Vælg **Push notifikationer** i menubaren i venstre side og tryk herefter på **[Kategorier](https://manager.starti.app/push-categories)**. Opret en ny kategori [#opret-en-ny-kategori] Skriv navnet på din nye kategori i inputfeltet under "Tilføj ny kategori" og tryk på **Tilføj** eller Enter. Kategorien oprettes med det samme og vises i oversigten. Når du opretter en kategori, får den automatisk tildelt et **Topic** — det er det interne navn, som en udvikler bruger til at tilføje abonnenter til kategorien og til at sende push notifikationer til dem. Topic-navnet kan **ikke ændres**, efter det er oprettet, så sørg for at kategorinavnet er korrekt fra starten. Vælg om kategorien skal vises i appen [#vælg-om-kategorien-skal-vises-i-appen] Alle nye kategorier er som standard synlige i appen. Fjern fluebenet i **Vis i app**, hvis du vil skjule kategorien for brugerne — den kan stadig bruges til afsendelse fra Manager. *** Administrer dine kategorier [#administrer-dine-kategorier] Gå til **Push notifikationer -> [Kategorier](https://manager.starti.app/push-categories)** i menuen for at redigere eksisterende kategorier: * **Omdøb** en kategori ved at klikke på blyantsikonet ud for navnet på kategorien * **Sorter** kategorier i den ønskede rækkefølge ved at trække og slippe dem på de 6 prikker til venstre for kategorinavnet * **Slet** en kategori ved at klikke på skraldespandikonet Sletning af en kategori kan ikke fortrydes. Brugere, der abonnerer på kategorien, vil ikke længere modtage notifikationer sendt til den. *** Se også [#se-også] Sådan sender du en push notifikation til dine brugere Lad brugere abonnere og afmelde push kategorier direkte i appen # Tilføj testmobiler Sådan tilføjer du testmobiler i starti.app Manager [#sådan-tilføjer-du-testmobiler-i-startiapp-manager] Før du går i gang [#før-du-går-i-gang] Testmobiler giver dig mulighed for at sende en push notifikation eller aktivere et Introflow på udvalgte enheder, inden du sender ud til alle dine brugere. Det er en god måde at tjekke, at alt ser korrekt ud, og at links virker — på tværs af både iOS og Android. For at tilføje en enhed som testmobil, skal du have din app installeret på enheden og sørge for, at du har den seneste opdatering til appen. Sådan tilføjer du en testmobil [#sådan-tilføjer-du-en-testmobil] Processen foregår i to dele: Først aktiverer du enheden fra selve appen, og derefter godkender du den i Manager. Åbn appen på testenheden [#åbn-appen-på-testenheden] Sørg for, at appen er åben og kører på den enhed, du vil tilføje som testmobil. Ryst mobilen [#ryst-mobilen] Ryst mobilen, mens appen er åben. Gå ud på hjemmeskærmen og åbn appen igen [#gå-ud-på-hjemmeskærmen-og-åbn-appen-igen] Gå ud til hjemmeskærmen og åbn herefter appen igen. Det er ikke nødvendigt at lukke appen helt ned. Ryst mobilen igen [#ryst-mobilen-igen] Ryst mobilen endnu en gang med appen åben. Det hele skal ske inden for 10 sekunder. Enheden vises herefter i Manager efter cirka 30 sekunder og er synlig i op til 2 minutter. Log ind i Manager [#log-ind-i-manager] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Testmobiler [#åbn-testmobiler] Vælg **[Testmobiler](https://manager.starti.app/test-devices)** i menubaren i venstre side. Find enheden under "Tilgængelige enheder" [#find-enheden-under-tilgængelige-enheder] Enheden dukker op under **Tilgængelige enheder** ca. 30 sekunder efter, du har gennemført Del 1. Den er synlig i op til 2 minutter — så du skal være klar til at godkende den. En farvet bjælke under enheden viser, hvor lang tid der er tilbage, inden den forsvinder fra listen. Tilføj enheden [#tilføj-enheden] Tryk på **Tilføj** ud for enheden. Giv enheden et navn, der gør den nem at genkende — fx *Mikkels iPhone* eller *Test Android*. Tryk herefter på **Tilføj** for at bekræfte. Enheden vises nu under **Nuværende testmobiler**. Tilføj aldrig en enhed, du ikke kender. Vi anbefaler, at du tilføjer mindst én iOS-enhed og én Android-enhed som testmobiler. Eftersom push notifikationer kan se forskelligt ud på de to platforme, giver det dig den bedste kontrol over, hvordan notifikationen ser ud inden udsendelsen. *** Administrer dine testmobiler [#administrer-dine-testmobiler] Gå til **[Testmobiler](https://manager.starti.app/test-devices)** i menuen for at se og redigere dine registrerede enheder: * **Omdøb** en enhed ved at klikke på blyantikonet ud for enhedens navn * **Fjern** en enhed ved at klikke på skraldespandikonet Fjernede testmobiler kan ikke gendannes automatisk. Du skal gennemgå registreringsprocessen igen, hvis du vil tilføje enheden på ny. *** Se også [#se-også] Sådan sender du en push notifikation — inkl. test til dine testmobiler Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Tilføj brugere til starti.app Manager Sådan tilføjer du brugere til starti.app Manager [#sådan-tilføjer-du-brugere-til-startiapp-manager] Hvad er Manager-brugere? [#hvad-er-manager-brugere] Manager-brugere er de personer, der har adgang til at opsætte og administrere din app via starti.app Manager. Det kan fx være kolleger, der skal hjælpe med at sende push notifikationer eller opdatere indhold i appen. Brugere tilføjes med deres e-mailadresse og logger ind med Microsoft, Google eller Apple — præcis som du selv gør. Vær opmærksom på, at når du tilføjer brugere i starti.app Manager, får de direkte adgang til at ændre indhold i din app. Tilføj derfor KUN brugere, du har tillid til. Tilføj en ny bruger [#tilføj-en-ny-bruger] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Brugere [#åbn-brugere] Vælg **[Brugere](https://manager.starti.app/users)** i menubaren i venstre side. Tryk på "Tilføj bruger" [#tryk-på-tilføj-bruger] Tryk på knappen **Tilføj bruger** i øverste højre hjørne af oversigten. Udfyld e-mailadressen [#udfyld-e-mailadressen] Skriv e-mailadressen på den person, du vil give adgang, i feltet **Email**. Tryk herefter på **Opret bruger** for at gemme. Brugeren vises nu i oversigten under **Manager-brugere** og kan logge ind i starti.app Manager med den registrerede e-mailadresse. Den nye bruger modtager ikke automatisk en invitationsmail. Sørg for selv at give personen besked om, at de nu har adgang, og at de skal logge ind på [manager.starti.app](https://manager.starti.app/login). *** Administrer dine brugere [#administrer-dine-brugere] Gå til **[Brugere](https://manager.starti.app/users)** i menuen for at se og redigere eksisterende Manager-brugere: * **Fjern** en bruger ved at klikke på skraldespandikonet ud for brugeren Sletning af en bruger fjerner vedkommendes adgang til Manager med det samme. Handlingen kan ikke fortrydes — men du kan altid tilføje brugeren igen. *** Se også [#se-også] Kom i gang med at opsætte det første, nye brugere møder i appen Sådan sender du en push notifikation til dine brugere # Generelt om push notifikationer Generelt om push notifikationer [#generelt-om-push-notifikationer] Push notifikationer er et af de mest direkte og effektive redskaber, du har til rådighed, når du vil nå dine brugere. Her får du et overblik over, hvad push notifikationer er, hvordan de ser ud, og hvad du kan bruge dem til. *** Hvad er en push notifikation? [#hvad-er-en-push-notifikation] En push notifikation er en kort besked, der sendes direkte til brugerens telefon – lidt som en sms, men fra en app. Beskeden vises på hjemmeskærmen, også selvom brugeren ikke har appen åbnet. En push notifikation bruges til at fange og fastholde brugerens opmærksomhed ved at levere en vigtig eller relevant besked på det rigtige tidspunkt. Målet er, at brugeren udfører den handling, du ønsker. Når brugeren trykker på notifikationen, sendes de direkte ind i din app – ideelt set lige til den vare, det tilbud eller den side, beskeden handler om. Derfor er det vigtigt, at både teksten og linket er gennemtænkt, så brugeren ledes til det rigtige sted. *** Sådan ser en push notifikation ud [#sådan-ser-en-push-notifikation-ud] En push notifikation består som regel af: * **Afsendernavn og logo** — din apps navn og ikon * **Titel** — en overskrift på maks 30 tegn * **Tekst** — en kort beskrivelse eller opfordring til handling på maks 90 tegn * **Link** — et "usynligt" link til den side i appen, notifikationen skal lede til **Push notifikationer vises på tre steder:** * På den **låste skærm**, hvis telefonen er låst * Øverst på **hjemmeskærmen** som en banner-besked, når telefonen er i brug * I **notifikationscentret**, hvor brugeren kan se alle tidligere notifikationer *** Hvorfor bruge push notifikationer? [#hvorfor-bruge-push-notifikationer] Push notifikationer giver dig en direkte kommunikationskanal til brugeren – du behøver ikke vente på, at de selv åbner appen, besøger din webshop eller læser et nyhedsbrev. Folk tjekker deres telefon langt oftere, end de læser mails. Beskeden når brugeren med det samme. Du bestemmer timingen og sætter dagsordenen, uden at brugeren skal gøre noget aktivt. Med push notifikationer kan du minde brugeren om tilbud, nyheder eller indhold, de har vist interesse for – og få dem til at vende tilbage igen og igen. Relevante beskeder baseret på brugerens adfærd – fx "Du glemte noget i kurven" eller "Der er kommet en ny video, som matcher dine interesser" – skaber en oplevelse af, at du kender og forstår brugeren. Det opbygger tillid over tid. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i antallet af aktive brugere i appen på de dage, du sender notifikationer ud. *** Fire typer push notifikationer [#fire-typer-push-notifikationer] Push notifikationer kan inddeles i fire typer baseret på, hvad der udløser dem, og hvad formålet er. Sendes, fordi brugeren har gjort noget bestemt — eller ikke har gjort det. Sættes op til at aktiveres automatisk af den enkelte bruger. **Eksempel:** Trigger: Brugeren har lagt varer i kurven, men ikke gennemført købet. Besked: *"Hov… Du glemte noget i din kurv."* Formål: At få brugeren til at gennemføre købet. Automatiske statusopdateringer om noget, der sker i systemet — fx ændringer i brugerens ordre, status eller abonnement. Det er service, ikke marketing. Formålet er at holde brugeren opdateret, uden at de selv skal tjekke appen. **Eksempel:** Trigger: Brugerens ordrestatus ændres. Besked: *"Din pakke er blevet sendt 📦."* Formål: At informere om ordrestatus og skabe tryghed. Sendes til mange brugere på én gang og er ikke baseret på adfærd, men på planlagte kampagner, tilbud eller generelle nyheder. Bruges til at skabe opmærksomhed, øge salg eller informere bredt. **Eksempel:** Trigger: Kampagne eller nyhed, du vil informere om. Besked: *"Weekendtilbud! 30 % på alt."* Formål: At øge salg og engagement. Tilpasses den enkelte bruger baseret på deres vaner, interesser eller data. Skaber høj relevans og engagement ved at ramme det, brugeren faktisk interesserer sig for — og på det tidspunkt, de typisk reagerer. **Eksempel:** Trigger: Brugerens tidligere køb, interesser eller vaner. Besked: *"Din yndlingskategori har fået nye produkter 🧥."* Formål: At øge relevans og engagement med personligt tilpassede beskeder. *** Hvad skal du måle på? [#hvad-skal-du-måle-på] KPI'er (Key Performance Indicators) er de nøgletal, du bruger til at vurdere, om dine push notifikationer virker. De hjælper dig med at finde ud af, hvad der fungerer, og hvad der skal justeres. | KPI | Hvad måler det? | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Leveringsrate** | Hvor mange notifikationer når faktisk frem til brugernes enheder? Hjælper med at afdække tekniske problemer. | | **Åbningsrate / klikrate** | Hvor mange brugere åbner eller klikker på notifikationen? Viser, om indholdet er relevant og engagerende. | | **Konverteringsrate** | Hvor mange brugere udfører den ønskede handling efter at have klikket — fx et køb eller en tilmelding? Måler notifikationens direkte effekt på forretningen. | | **Afmeldinger** | Hvor mange vælger push notifikationer fra? Indikerer, om frekvensen eller indholdet er forstyrrende. | Hold øje med antallet af aktive brugere i appen, når du sender en push notifikation ud. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i aktiviteten på de dage, du sender. *** Den gode og den dårlige push notifikation [#den-gode-og-den-dårlige-push-notifikation] Længden på teksten er afgørende for, hvordan beskeden vises på brugerens telefon. **Den gode push notifikation** er kort, tydelig og fuldt synlig – uden at blive afkortet. Hele beskeden kan læses direkte på skærmen, så brugeren straks forstår, hvad det handler om, og hvad de skal gøre. Trykker brugeren på notifikationen, sendes de direkte ind på den relevante side i appen. **Den dårlige push notifikation** er for lang. Teksten kan ikke vises fuldt ud og bliver afkortet med tre prikker ("..."), så brugeren ikke kan læse hele budskabet. Den sender desuden bare brugeren ind på forsiden af appen – og det er herefter op til dem selv at finde vej til den del af appen, notifikationen omhandler. *** Se også [#se-også] Trin-for-trin guide til at oprette og sende en push notifikation i starti.app Manager Automatiser dine første notifikationer til nye brugere med et velkomstflow # Sådan sender du en push notifikation Sådan sender du en push notifikation [#sådan-sender-du-en-push-notifikation] Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Push notifikationer styres fra starti.app Manager, og du skal være logget ind med dit brand for at komme i gang. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå disse guides før du kan sende push notifikationer: * [Opret kategorier](/academy/manager/kom-godt-i-gang/push-kategorier) * [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow) Step by step guide til at sende en push notifikation [#step-by-step-guide-til-at-sende-en-push-notifikation] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Send notifikation [#åbn-send-notifikation] Gå til **[Send notifikation](https://manager.starti.app/send-push)** i menubaren i venstre side. Vælg modtagere [#vælg-modtagere] Vælg hvem der skal modtage notifikationen: * Vælg **Send til alle** for at sende til samtlige brugere, der har tilladt push notifikationer * Vælg en eller flere **kategorier** for kun at sende til en bestemt gruppe af brugere Du kan se antallet af abonnenter for hver mulighed direkte i oversigten. Hvis du endnu ikke har oprettet kategorier i din app, kan du følge denne guide: [Opret kategorier](/academy/manager/kom-godt-i-gang/push-kategorier) Udfyld din push notifikation [#udfyld-din-push-notifikation] Udfyld felterne: * **Titel** (pas på længden — en indikator viser dig, hvornår titlen risikerer at blive afkortet på telefoner) * **Tekst** (ligesom titlen vises en indikator for anbefalet tekstlængde) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Brug preview til at tjekke beskeden [#brug-preview-til-at-tjekke-beskeden] Under inputfelterne vises et live preview af, hvordan notifikationen ser ud på en telefon. Brug det til at sikre, at titel og tekst ikke bliver afkortet, og at beskeden ser korrekt ud. Send eller planlæg din push notifikation [#send-eller-planlæg-din-push-notifikation] Når du har udfyldt notifikationen, har du tre muligheder: 1. Send den ud **med det samme** ved at trykke på **Send push notification** 2. Send en **test til dine egne testmobiler** ved at vælge en eller flere testenheder under "Eller send til testmobiler" til venstre for inputfelterne 3. **Planlæg notifikationen** til et bestemt tidspunkt ved at udfylde **Dato** og **Tid** og derefter trykke på **Send push notification** Hvis du endnu ikke har tilføjet testmobiler, skal du følge denne guide: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) **"Planlæg notifikation"** er en nyttig funktion — fx til kampagner, tilbud eller lanceringer, der gælder fra et bestemt tidspunkt. Funktionen kan også bruges, hvis du ved, at en notifikation skal sendes i morgen kl. 10, men det er i dag, du har tid til at oprette den. Vi anbefaler, at du altid tester din push notifikation på flere interne testenheder (gerne både iOS og Android), inden du sender den ud til brugerne. På den måde kan du sikre dig, at notifikationen ser korrekt ud på forskellige enheder, og at linket sender brugeren ind på den rigtige side i appen. *** Tjekliste før du sender din push notifikation [#tjekliste-før-du-sender-din-push-notifikation] * Har du en fængende titel? * Har du en tekst, der kort og præcist beskriver, hvad det handler om, og hvad du ønsker, at brugeren skal gøre? * Virker URL'en? Leder den det rigtige sted hen? * Har du valgt de rigtige modtagere? Det er måske ikke alle brugere, den enkelte notifikation er relevant for. * Skal notifikationen sendes med det samme, eller skal den planlægges til senere? * Hvis notifikationen skal sendes senere — har du valgt den rigtige dato og tid? Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Sådan opsætter du et velkomstflow Sådan opsætter du et velkomstflow med push notifikationer [#sådan-opsætter-du-et-velkomstflow-med-push-notifikationer] Hvad er et velkomstflow? [#hvad-er-et-velkomstflow] Når en bruger installerer appen og accepterer push notifikationer, modtager de automatisk en serie af velkomst notifikationer – det kalder vi for et Velkomstflow. Flowet hjælper med at engagere brugerne i de første kritiske dage efter installation, og det giver dig mulighed for at give dem en god introduktion til appen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager, og at Velkomstflowet er tilgængeligt i din starti.app løsning. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager, eller hvis du ønsker at opgradere din løsning, så Velkomstflowet er inkluderet. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå denne guide, før du kan aktivere Velkomstflowet: * [Opsæt Introflow](/academy/manager/kom-godt-i-gang/introflow) Indstillinger for velkomstflowet [#indstillinger-for-velkomstflowet] Inden du begynder at tilføje notifikationer, er der to indstillinger, du skal tage stilling til: Aktiverer du denne funktion, bliver velkomstflowet sendt ud til alle de eksisterende brugere, der allerede har installeret appen og accepteret push notifikationer. Lader du funktionen være deaktiveret, bliver flowet kun sendt ud til fremtidige nye app-brugere. Her kan du sætte en overordnet regel for alle notifikationerne i dit velkomstflow – fx at der aldrig bliver sendt en notifikation ud om natten. Vær opmærksom på, at denne funktion ikke bestemmer det konkrete udsendelsestidspunkt for den enkelte notifikation. Det indstilles på hver enkelt notifikation. Step by step guide til at tilføje en notifikation [#step-by-step-guide-til-at-tilføje-en-notifikation] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Velkomstflow [#åbn-velkomstflow] Gå til **Push notifikationer** → **[Velkomstflow](https://manager.starti.app/welcome-flow)** i menubaren i venstre side. Tilføj en notifikation [#tilføj-en-notifikation] Tryk på **+ Tilføj notifikation**, og tryk derefter på pilen for at folde notifikationen ud. Vælg udsendelsestidspunkt [#vælg-udsendelsestidspunkt] Vælg hvornår notifikationen skal sendes. Her har du to muligheder: * **Samme dag som installation:** Vælg **Minimum ventetid** og træk slideren til det antal minutter eller timer, der skal gå, før brugeren modtager notifikationen. * **Dagen efter eller senere:** Indtast det antal midnat, der skal passere, før notifikationen sendes. Indtaster du "1", sendes notifikationen dagen efter installation. Husk også at angive, hvilket tidspunkt på dagen den skal sendes. Udfyld din notifikation [#udfyld-din-notifikation] Udfyld felterne: * **Titel** (maks 30 tegn) * **Tekst** (maks 90 tegn) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Tilføj de næste notifikationer [#tilføj-de-næste-notifikationer] Gentag processen for de øvrige notifikationer i dit flow. Vær opmærksom på, at tidspunktet for hver notifikation regnes ud fra den forrige notifikation i rækken – ikke fra installationstidspunktet. **Eksempel:** Sætter du notifikation 2 til udsendelse efter 2 dage, sendes den 2 dage efter notifikation 1 – og ikke nødvendigvis 2 dage efter, at brugeren installerede appen. Vi anbefaler, at du starter med 5 notifikationer fordelt over de første 5 dage. Du kan altid tilføje flere notifikationer til flowet efterfølgende. *** Test dit velkomstflow [#test-dit-velkomstflow] Inden du aktiverer velkomstflowet, er det en god idé at teste det grundigt. Vi anbefaler, at du tester på flere testenheder – gerne både iOS og Android – for at sikre, at brugerne får den bedste oplevelse uanset mobil og styresystem. Hvis du endnu ikke har tilføjet testenheder, skal du følge denne guide først: [Tilføj testmobil](/academy/manager/kom-godt-i-gang/testmobiler) Du kan teste på to måder, og vi anbefaler, at du gennemgår begge: Du kan sende en test af hver enkelt notifikation til en testenhed. Det gør du ved at folde notifikationen ud, scrolle ned til bunden og trykke på pilen ud for **Send test**. Vælg den ønskede testenhed og tryk **Send test**. Tjek følgende, når du tester: * Bliver titel og tekst vist fuldt ud, eller bliver de afkortet? Husk, at der kan være forskel på, hvordan notifikationen vises på iOS og Android. * Er der stave- eller tastefejl? * Sender URL'en brugeren til den rigtige side i appen? Når du tester flowet samlet, oplever du det præcis som dine brugere kommer til at opleve det — med den reelle ventetid mellem hver notifikation. Det giver dig mulighed for at vurdere, om den samlede rejse giver mening. For at teste hele flowet skal du scrolle ned til sektionen **Test velkomstflow på enhed** nederst på siden, vælge en testenhed og trykke **Start velkomstflow**. *** Aktivér dit velkomstflow [#aktivér-dit-velkomstflow] Når du har oprettet og testet dit velkomstflow, er du klar til at aktivere det. Tryk på **Aktivér velkomstflow** — flowet bliver aktiveret med det samme, og nye brugere, der accepterer push notifikationer, sættes automatisk i kø til at modtage det. Aktivér først velkomstflowet, når du er helt færdig med at oprette og teste det. *** Tjekliste før du aktiverer dit velkomstflow [#tjekliste-før-du-aktiverer-dit-velkomstflow] * Har du oprettet alle de notifikationer, du ønsker i flowet? * Er der en fængende titel på hver notifikation? * Er teksten på hver notifikation kort, præcis og handlingsorienteret? * Virker URL'erne? Leder de til det rigtige sted i appen? * Er rækkefølgen og timingen logisk set fra brugerens perspektiv? * Har du testet de enkelte notifikationer på en testenhed? * Har du testet hele flowet samlet — gerne på både iOS og Android? * Har du taget stilling til, om eksisterende brugere skal inkluderes? * Har du sat et afsendelsestidspunkt, der sikrer, at notifikationer ikke sendes om natten? Se også [#se-også] Sådan opretter og sender du en manuel push notifikation til dine brugere Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Opret og administrer API nøgler Opret og administrer API nøgler [#opret-og-administrer-api-nøgler] Hvad er API nøgler? [#hvad-er-api-nøgler] API nøgler giver dig mulighed for at sende push notifikationer programmatisk — fx fra dit eget system, en webshop eller et automatiseringsværktøj — uden at du manuelt skal logge ind i starti.app Manager. Hver API nøgle er knyttet til dit brand og har tilladelse til at sende push notifikationer via starti.app's API. En API nøgle vises kun én gang, når den oprettes. Sørg for at gemme den et sikkert sted med det samme — du kan ikke hente den igen bagefter. Opret en ny API nøgle [#opret-en-ny-api-nøgle] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn API nøgler [#åbn-api-nøgler] Gå til **[API nøgler](https://manager.starti.app/api-keys)** i menubaren i venstre side. Start oprettelse [#start-oprettelse] Klik på **Opret ny nøgle** øverst til højre på siden. Giv nøglen et navn [#giv-nøglen-et-navn] Skriv et beskrivende navn til nøglen i feltet **Navn** — fx "Webshop integration" eller "Newsletter automation". Navnet hjælper dig med at holde styr på, hvilke systemer der bruger hvilken nøgle. Gem nøglen sikkert [#gem-nøglen-sikkert] Klik på **Opret nøgle**. Din nye API nøgle vises nu i en boks på skærmen. Klik på kopier-ikonet og gem nøglen et sikkert sted — fx i din adgangskodemanager eller som en miljøvariabel i dit system. Klik herefter på **Jeg har gemt nøglen** for at lukke dialogen. Nøglen vises kun denne ene gang. Har du ikke gemt den, skal du slette nøglen og oprette en ny. *** Administrer dine API nøgler [#administrer-dine-api-nøgler] Under **Aktive API nøgler** kan du se en oversigt over alle nøgler, der er oprettet til dit brand. For hver nøgle vises: * **Navn** — det navn, du gav nøglen ved oprettelsen * **Rettigheder** — hvad nøglen har adgang til (pt. `PushNotifications/Send`) * **Oprettet** — hvornår nøglen blev oprettet * **Oprettet af** — hvilken bruger der oprettede nøglen * **Sidst brugt** — hvornår nøglen sidst blev brugt til at kalde API'et * **Forbrug** — hvor mange gange nøglen har været brugt Slet en API nøgle [#slet-en-api-nøgle] Klik på skraldespandikonet ud for den nøgle, du vil slette, og bekræft sletningen i den dialog, der vises. Sletning af en API nøgle kan ikke fortrydes. Alle systemer, der bruger nøglen, vil øjeblikkeligt miste adgang og holde op med at fungere. Sørg for at opdatere dine systemer, inden du sletter en nøgle. *** Opret separate nøgler til hvert af dine systemer eller integrationer. Så kan du nemt tilbagekalde adgangen for ét system uden at påvirke de andre. Se også [#se-også] Lær at sende push notifikationer manuelt fra starti.app Manager Tilføj HTML-snippetten til din hjemmeside for at integrere appen # Integrer din app med din hjemmeside Integrer din app med din hjemmeside [#integrer-din-app-med-din-hjemmeside] Hvad er hjemmesideopsætning? [#hvad-er-hjemmesideopsætning] Hjemmesideopsætning giver dig det HTML-kodestykke (snippet), du skal indsætte på din hjemmeside for at integrere din starti.app-app med dit site. Snippetten er unik for dit brand og genereres automatisk — du skal blot kopiere den og sætte den ind på din hjemmeside. Integrationen gør det muligt for din hjemmeside og din app at arbejde tæt sammen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til hjemmesidens kildekode eller et CMS, der giver dig mulighed for at redigere i HEAD-elementet. Hvis du er i tvivl, kan du kontakte den, der administrerer din hjemmeside. Ring eller skriv til os, hvis snippetten ikke vises i starti.app Manager — det kan betyde, at den endnu ikke er genereret for dit brand. Sådan finder og kopierer du din snippet [#sådan-finder-og-kopierer-du-din-snippet] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Hjemmesideopsætning [#åbn-hjemmesideopsætning] Gå til **[Hjemmesideopsætning](https://manager.starti.app/website-setup)** i menubaren i venstre side. Kopiér HTML-snippetten [#kopiér-html-snippetten] Under **HTML snippet** vises de linjer, der skal tilføjes til din hjemmeside. Klik på **Kopier** for at kopiere hele snippetten til udklipsholderen. Hvis der ikke vises nogen snippet, er den endnu ikke genereret for dit brand. Kontakt starti.app, så sørger vi for at få den klar til dig. Indsæt snippetten på din hjemmeside [#indsæt-snippetten-på-din-hjemmeside] Sæt det kopierede kodestykke ind i ``-elementet på din hjemmeside — gerne på alle sider, medmindre du kun ønsker integrationen på bestemte undersider. Ændringen skal gemmes og udgives på hjemmesiden, før integrationen er aktiv. *** Snippetten er unik for dit brand. Brug altid den snippet, der vises under dit eget brand i starti.app Manager — kopier den ikke fra et andet brand. Hvis din hjemmeside er bygget i et CMS som WordPress, Webflow eller lignende, er der typisk et felt til custom code i ``-sektionen under tema- eller sideindstillingerne. Det er her, du skal indsætte snippetten. Se også [#se-også] Opret API nøgler til at sende push notifikationer programmatisk # Generelt om push notifikationer Generelt om push notifikationer [#generelt-om-push-notifikationer] Push notifikationer er et af de mest direkte og effektive redskaber, du har til rådighed, når du vil nå dine brugere. Her får du et overblik over, hvad push notifikationer er, hvordan de ser ud, og hvad du kan bruge dem til. *** Hvad er en push notifikation? [#hvad-er-en-push-notifikation] En push notifikation er en kort besked, der sendes direkte til brugerens telefon – lidt som en sms, men fra en app. Beskeden vises på hjemmeskærmen, også selvom brugeren ikke har appen åbnet. En push notifikation bruges til at fange og fastholde brugerens opmærksomhed ved at levere en vigtig eller relevant besked på det rigtige tidspunkt. Målet er, at brugeren udfører den handling, du ønsker. Når brugeren trykker på notifikationen, sendes de direkte ind i din app – ideelt set lige til den vare, det tilbud eller den side, beskeden handler om. Derfor er det vigtigt, at både teksten og linket er gennemtænkt, så brugeren ledes til det rigtige sted. *** Sådan ser en push notifikation ud [#sådan-ser-en-push-notifikation-ud] En push notifikation består som regel af: * **Afsendernavn og logo** — din apps navn og ikon * **Titel** — en overskrift på maks 30 tegn * **Tekst** — en kort beskrivelse eller opfordring til handling på maks 90 tegn * **Link** — et "usynligt" link til den side i appen, notifikationen skal lede til **Push notifikationer vises på tre steder:** * På den **låste skærm**, hvis telefonen er låst * Øverst på **hjemmeskærmen** som en banner-besked, når telefonen er i brug * I **notifikationscentret**, hvor brugeren kan se alle tidligere notifikationer *** Hvorfor bruge push notifikationer? [#hvorfor-bruge-push-notifikationer] Push notifikationer giver dig en direkte kommunikationskanal til brugeren – du behøver ikke vente på, at de selv åbner appen, besøger din webshop eller læser et nyhedsbrev. Folk tjekker deres telefon langt oftere, end de læser mails. Beskeden når brugeren med det samme. Du bestemmer timingen og sætter dagsordenen, uden at brugeren skal gøre noget aktivt. Med push notifikationer kan du minde brugeren om tilbud, nyheder eller indhold, de har vist interesse for – og få dem til at vende tilbage igen og igen. Relevante beskeder baseret på brugerens adfærd – fx "Du glemte noget i kurven" eller "Der er kommet en ny video, som matcher dine interesser" – skaber en oplevelse af, at du kender og forstår brugeren. Det opbygger tillid over tid. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i antallet af aktive brugere i appen på de dage, du sender notifikationer ud. *** Fire typer push notifikationer [#fire-typer-push-notifikationer] Push notifikationer kan inddeles i fire typer baseret på, hvad der udløser dem, og hvad formålet er. Sendes, fordi brugeren har gjort noget bestemt — eller ikke har gjort det. Sættes op til at aktiveres automatisk af den enkelte bruger. **Eksempel:** Trigger: Brugeren har lagt varer i kurven, men ikke gennemført købet. Besked: *"Hov… Du glemte noget i din kurv."* Formål: At få brugeren til at gennemføre købet. Automatiske statusopdateringer om noget, der sker i systemet — fx ændringer i brugerens ordre, status eller abonnement. Det er service, ikke marketing. Formålet er at holde brugeren opdateret, uden at de selv skal tjekke appen. **Eksempel:** Trigger: Brugerens ordrestatus ændres. Besked: *"Din pakke er blevet sendt 📦."* Formål: At informere om ordrestatus og skabe tryghed. Sendes til mange brugere på én gang og er ikke baseret på adfærd, men på planlagte kampagner, tilbud eller generelle nyheder. Bruges til at skabe opmærksomhed, øge salg eller informere bredt. **Eksempel:** Trigger: Kampagne eller nyhed, du vil informere om. Besked: *"Weekendtilbud! 30 % på alt."* Formål: At øge salg og engagement. Tilpasses den enkelte bruger baseret på deres vaner, interesser eller data. Skaber høj relevans og engagement ved at ramme det, brugeren faktisk interesserer sig for — og på det tidspunkt, de typisk reagerer. **Eksempel:** Trigger: Brugerens tidligere køb, interesser eller vaner. Besked: *"Din yndlingskategori har fået nye produkter 🧥."* Formål: At øge relevans og engagement med personligt tilpassede beskeder. *** Hvad skal du måle på? [#hvad-skal-du-måle-på] KPI'er (Key Performance Indicators) er de nøgletal, du bruger til at vurdere, om dine push notifikationer virker. De hjælper dig med at finde ud af, hvad der fungerer, og hvad der skal justeres. | KPI | Hvad måler det? | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Leveringsrate** | Hvor mange notifikationer når faktisk frem til brugernes enheder? Hjælper med at afdække tekniske problemer. | | **Åbningsrate / klikrate** | Hvor mange brugere åbner eller klikker på notifikationen? Viser, om indholdet er relevant og engagerende. | | **Konverteringsrate** | Hvor mange brugere udfører den ønskede handling efter at have klikket — fx et køb eller en tilmelding? Måler notifikationens direkte effekt på forretningen. | | **Afmeldinger** | Hvor mange vælger push notifikationer fra? Indikerer, om frekvensen eller indholdet er forstyrrende. | Hold øje med antallet af aktive brugere i appen, når du sender en push notifikation ud. Bruger du push notifikationer rigtigt, vil du kunne se et tydeligt spike i aktiviteten på de dage, du sender. *** Den gode og den dårlige push notifikation [#den-gode-og-den-dårlige-push-notifikation] Længden på teksten er afgørende for, hvordan beskeden vises på brugerens telefon. **Den gode push notifikation** er kort, tydelig og fuldt synlig – uden at blive afkortet. Hele beskeden kan læses direkte på skærmen, så brugeren straks forstår, hvad det handler om, og hvad de skal gøre. Trykker brugeren på notifikationen, sendes de direkte ind på den relevante side i appen. **Den dårlige push notifikation** er for lang. Teksten kan ikke vises fuldt ud og bliver afkortet med tre prikker ("..."), så brugeren ikke kan læse hele budskabet. Den sender desuden bare brugeren ind på forsiden af appen – og det er herefter op til dem selv at finde vej til den del af appen, notifikationen omhandler. *** Se også [#se-også] Trin-for-trin guide til at oprette og sende en push notifikation i starti.app Manager Automatiser dine første notifikationer til nye brugere med et velkomstflow # Se planlagte push notifikationer Se planlagte push notifikationer [#se-planlagte-push-notifikationer] Hvad er planlagte push notifikationer? [#hvad-er-planlagte-push-notifikationer] Når du opretter en push notifikation og vælger at sende den på et bestemt tidspunkt, bliver den lagt i kø som en planlagt notifikation. På denne side kan du se alle dine kommende planlagte notifikationer og slette dem, hvis du fortryder. Sådan finder du dine planlagte notifikationer [#sådan-finder-du-dine-planlagte-notifikationer] Vælg **[Send notifikation](https://manager.starti.app/send-push)** i menubaren i venstre side og tryk herefter på **[Se planlagte notifikationer](https://manager.starti.app/scheduled-notifications)** () > under sektionen Push notification. Her ser du en liste over alle notifikationer, der er planlagt til at blive sendt på et fremtidigt tidspunkt. For hver notifikation kan du se: * **Titel** på notifikationen * **Tekst** i notifikationen * **Dato og tidspunkt** for afsendelse * **Modtagere** — hvem notifikationen er planlagt til at blive sendt til Slet en planlagt notifikation [#slet-en-planlagt-notifikation] Hvis du ønsker at annullere en planlagt notifikation, inden den bliver sendt, kan du slette den fra listen. En slettet notifikation kan ikke gendannes. Hvis du fortryder, skal du oprette notifikationen igen. *** Se også [#se-også] Opret og send en push notifikation — eller planlæg den til et bestemt tidspunkt Se historikken over alle tidligere sendte push notifikationer # Sådan sender du en push notifikation Sådan sender du en push notifikation [#sådan-sender-du-en-push-notifikation] Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager. Push notifikationer styres fra starti.app Manager, og du skal være logget ind med dit brand for at komme i gang. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå disse guides før du kan sende push notifikationer: * [Opret kategorier](/da/academy/manager/getting-started/push-categories) * [Opsæt Introflow](/da/academy/manager/getting-started/introflow) Step by step guide til at sende en push notifikation [#step-by-step-guide-til-at-sende-en-push-notifikation] Log ind [#log-ind] Åben [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Send notifikation [#åbn-send-notifikation] Gå til **[Send notifikation](https://manager.starti.app/send-push)** i menubaren i venstre side. Vælg modtagere [#vælg-modtagere] Vælg hvem der skal modtage notifikationen: * Vælg **Send til alle** for at sende til samtlige brugere, der har tilladt push notifikationer * Vælg en eller flere **kategorier** for kun at sende til en bestemt gruppe af brugere Du kan se antallet af abonnenter for hver mulighed direkte i oversigten. Hvis du endnu ikke har oprettet kategorier i din app, kan du følge denne guide: [Opret kategorier](/da/academy/manager/getting-started/push-categories) Udfyld din push notifikation [#udfyld-din-push-notifikation] Udfyld felterne: * **Titel** (pas på længden — en indikator viser dig, hvornår titlen risikerer at blive afkortet på telefoner) * **Tekst** (ligesom titlen vises en indikator for anbefalet tekstlængde) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Brug preview til at tjekke beskeden [#brug-preview-til-at-tjekke-beskeden] Under inputfelterne vises et live preview af, hvordan notifikationen ser ud på en telefon. Brug det til at sikre, at titel og tekst ikke bliver afkortet, og at beskeden ser korrekt ud. Send eller planlæg din push notifikation [#send-eller-planlæg-din-push-notifikation] Når du har udfyldt notifikationen, har du tre muligheder: 1. Send den ud **med det samme** ved at trykke på **Send push notification** 2. Send en **test til dine egne testmobiler** ved at vælge en eller flere testenheder under "Eller send til testmobiler" til venstre for inputfelterne 3. **Planlæg notifikationen** til et bestemt tidspunkt ved at udfylde **Dato** og **Tid** og derefter trykke på **Send push notification** Hvis du endnu ikke har tilføjet testmobiler, skal du følge denne guide: [Tilføj testmobil](/da/academy/manager/getting-started/test-devices) **"Planlæg notifikation"** er en nyttig funktion — fx til kampagner, tilbud eller lanceringer, der gælder fra et bestemt tidspunkt. Funktionen kan også bruges, hvis du ved, at en notifikation skal sendes i morgen kl. 10, men det er i dag, du har tid til at oprette den. Vi anbefaler, at du altid tester din push notifikation på flere interne testenheder (gerne både iOS og Android), inden du sender den ud til brugerne. På den måde kan du sikre dig, at notifikationen ser korrekt ud på forskellige enheder, og at linket sender brugeren ind på den rigtige side i appen. *** Tjekliste før du sender din push notifikation [#tjekliste-før-du-sender-din-push-notifikation] * Har du en fængende titel? * Har du en tekst, der kort og præcist beskriver, hvad det handler om, og hvad du ønsker, at brugeren skal gøre? * Virker URL'en? Leder den det rigtige sted hen? * Har du valgt de rigtige modtagere? Det er måske ikke alle brugere, den enkelte notifikation er relevant for. * Skal notifikationen sendes med det samme, eller skal den planlægges til senere? * Hvis notifikationen skal sendes senere — har du valgt den rigtige dato og tid? Se også [#se-også] Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # Se sendte push notifikationer Se sendte push notifikationer [#se-sendte-push-notifikationer] Hvad er sendte push notifikationer? [#hvad-er-sendte-push-notifikationer] På denne side finder du en oversigt over de push notifikationer, der tidligere er sendt via starti.app Manager. Det giver dig et hurtigt overblik over, hvad der er blevet sendt, hvornår og til hvem. Sådan finder du dine sendte notifikationer [#sådan-finder-du-dine-sendte-notifikationer] Vælg **[Send notifikation](https://manager.starti.app/send-push)** i menubaren i venstre side og tryk herefter på **[Sendte notifikationer](https://manager.starti.app/notifications)** > under sektionen Push notification. Her ser du en liste over alle tidligere sendte notifikationer. For hver notifikation kan du se: * **Titel** på notifikationen * **Tekst** i notifikationen — klik for at folde ud, hvis beskeden er lang * **Dato** for afsendelse * **Modtagere** — hvor mange notifikationen blev sendt til * **Emne** — hvilken push-kategori notifikationen blev sendt til (fx "Alle" eller en bestemt kategori) * **Klik og klikrate** for hver notifikation Du kan også **gensende** en notifikation: klik på gensend-ikonet i højre side af en række for at åbne send-formularen med alle felter udfyldt på forhånd. Ret det du ønsker, og send den som en ny notifikation. *** Se også [#se-også] Opret og send en push notifikation — eller planlæg den til et bestemt tidspunkt Se og administrer dine kommende planlagte push notifikationer # Sådan opsætter du et velkomstflow Sådan opsætter du et velkomstflow med push notifikationer [#sådan-opsætter-du-et-velkomstflow-med-push-notifikationer] Hvad er et velkomstflow? [#hvad-er-et-velkomstflow] Når en bruger installerer appen og accepterer push notifikationer, modtager de automatisk en serie af velkomst notifikationer – det kalder vi for et Velkomstflow. Flowet hjælper med at engagere brugerne i de første kritiske dage efter installation, og det giver dig mulighed for at give dem en god introduktion til appen. Før du går i gang [#før-du-går-i-gang] Sørg for at du har adgang til starti.app Manager, og at Velkomstflowet er tilgængeligt i din starti.app løsning. Ring eller skriv til os, hvis du ikke har adgang til dit brand i starti.app Manager, eller hvis du ønsker at opgradere din løsning, så Velkomstflowet er inkluderet. Hvis det er første gang, du bruger starti.app Manager, skal du gennemgå denne guide, før du kan aktivere Velkomstflowet: * [Opsæt Introflow](/da/academy/manager/getting-started/introflow) Indstillinger for velkomstflowet [#indstillinger-for-velkomstflowet] Inden du begynder at tilføje notifikationer, er der to indstillinger, du skal tage stilling til: Aktiverer du denne funktion, bliver velkomstflowet sendt ud til alle de eksisterende brugere, der allerede har installeret appen og accepteret push notifikationer. Lader du funktionen være deaktiveret, bliver flowet kun sendt ud til fremtidige nye app-brugere. Her kan du sætte en overordnet regel for alle notifikationerne i dit velkomstflow – fx at der aldrig bliver sendt en notifikation ud om natten. Vær opmærksom på, at denne funktion ikke bestemmer det konkrete udsendelsestidspunkt for den enkelte notifikation. Det indstilles på hver enkelt notifikation. Step by step guide til at tilføje en notifikation [#step-by-step-guide-til-at-tilføje-en-notifikation] Log ind [#log-ind] Åbn [starti.app Manager](https://manager.starti.app/login) og log ind med Microsoft, Google eller Apple. Åbn Velkomstflow [#åbn-velkomstflow] Gå til **Push notifikationer** → **[Velkomstflow](https://manager.starti.app/welcome-flow)** i menubaren i venstre side. Tilføj en notifikation [#tilføj-en-notifikation] Tryk på **+ Tilføj notifikation**, og tryk derefter på pilen for at folde notifikationen ud. Vælg udsendelsestidspunkt [#vælg-udsendelsestidspunkt] Vælg hvornår notifikationen skal sendes. Her har du to muligheder: * **Samme dag som installation:** Vælg **Minimum ventetid** og træk slideren til det antal minutter eller timer, der skal gå, før brugeren modtager notifikationen. * **Dagen efter eller senere:** Indtast det antal midnat, der skal passere, før notifikationen sendes. Indtaster du "1", sendes notifikationen dagen efter installation. Husk også at angive, hvilket tidspunkt på dagen den skal sendes. Udfyld din notifikation [#udfyld-din-notifikation] Udfyld felterne: * **Titel** (maks 30 tegn) * **Tekst** (maks 90 tegn) * **URL** — hvor skal notifikationen sende brugeren hen? Efterlades feltet tomt, linker notifikationen til appens forside Tilføj de næste notifikationer [#tilføj-de-næste-notifikationer] Gentag processen for de øvrige notifikationer i dit flow. Vær opmærksom på, at tidspunktet for hver notifikation regnes ud fra den forrige notifikation i rækken – ikke fra installationstidspunktet. **Eksempel:** Sætter du notifikation 2 til udsendelse efter 2 dage, sendes den 2 dage efter notifikation 1 – og ikke nødvendigvis 2 dage efter, at brugeren installerede appen. Vi anbefaler, at du starter med 5 notifikationer fordelt over de første 5 dage. Du kan altid tilføje flere notifikationer til flowet efterfølgende. *** Test dit velkomstflow [#test-dit-velkomstflow] Inden du aktiverer velkomstflowet, er det en god idé at teste det grundigt. Vi anbefaler, at du tester på flere testenheder – gerne både iOS og Android – for at sikre, at brugerne får den bedste oplevelse uanset mobil og styresystem. Hvis du endnu ikke har tilføjet testenheder, skal du følge denne guide først: [Tilføj testmobil](/da/academy/manager/getting-started/test-devices) Du kan teste på to måder, og vi anbefaler, at du gennemgår begge: Du kan sende en test af hver enkelt notifikation til en testenhed. Det gør du ved at folde notifikationen ud, scrolle ned til bunden og trykke på pilen ud for **Send test**. Vælg den ønskede testenhed og tryk **Send test**. Tjek følgende, når du tester: * Bliver titel og tekst vist fuldt ud, eller bliver de afkortet? Husk, at der kan være forskel på, hvordan notifikationen vises på iOS og Android. * Er der stave- eller tastefejl? * Sender URL'en brugeren til den rigtige side i appen? Når du tester flowet samlet, oplever du det præcis som dine brugere kommer til at opleve det — med den reelle ventetid mellem hver notifikation. Det giver dig mulighed for at vurdere, om den samlede rejse giver mening. For at teste hele flowet skal du scrolle ned til sektionen **Test velkomstflow på enhed** nederst på siden, vælge en testenhed og trykke **Start velkomstflow**. *** Aktivér dit velkomstflow [#aktivér-dit-velkomstflow] Når du har oprettet og testet dit velkomstflow, er du klar til at aktivere det. Tryk på **Aktivér velkomstflow** — flowet bliver aktiveret med det samme, og nye brugere, der accepterer push notifikationer, sættes automatisk i kø til at modtage det. Aktivér først velkomstflowet, når du er helt færdig med at oprette og teste det. *** Tjekliste før du aktiverer dit velkomstflow [#tjekliste-før-du-aktiverer-dit-velkomstflow] * Har du oprettet alle de notifikationer, du ønsker i flowet? * Er der en fængende titel på hver notifikation? * Er teksten på hver notifikation kort, præcis og handlingsorienteret? * Virker URL'erne? Leder de til det rigtige sted i appen? * Er rækkefølgen og timingen logisk set fra brugerens perspektiv? * Har du testet de enkelte notifikationer på en testenhed? * Har du testet hele flowet samlet — gerne på både iOS og Android? * Har du taget stilling til, om eksisterende brugere skal inkluderes? * Har du sat et afsendelsestidspunkt, der sikrer, at notifikationer ikke sendes om natten? Se også [#se-også] Sådan opretter og sender du en manuel push notifikation til dine brugere Segmentér dine brugere med kategorier, så de kun modtager relevante notifikationer # 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. Right-click on the QR code on the [Flowlink page](https://manager.starti.app/flowlink) in starti.app Manager 2. Select **"Save image as..."** from the menu 3. Save the image 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. 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 # 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. *** 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. 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`) * **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 # 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 # 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 Users [#open-users] Select **[Users](https://manager.starti.app/users)** in the left-hand menu bar. 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 **[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 four sections: **App information**, **Contact details**, **Privacy policy**, and **D-U-N-S number**. 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. *** 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. *** 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 "Save settings"). 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. 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? * 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 # 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** 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** 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 # 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[View scheduled notifications](https://manager.starti.app/scheduled-notifications)** () > under the Push notification section. 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 left-hand menu bar. 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 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] Select **[Send notification](https://manager.starti.app/send-push)** in the left-hand menu bar and then click **[Sent notifications](https://manager.starti.app/notifications)** > under the Push notification section. 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