# 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
App version: {{ version || "unknown" }}
```
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.
Go to Custom definitions [#go-to-custom-definitions]
In the property settings column, click **Custom definitions**.
Create a custom dimension [#create-a-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` |
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**.
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**.
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)
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**.
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**.
Create a Google Cloud project [#create-a-google-cloud-project]
Select **Create a new Google Cloud project** and click **Save**.
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**
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**.
Set permissions [#set-permissions]
Under **Role**, select **Service Accounts** > **Service Account User**. Click **Continue**, then **Done**.
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.
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**
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
App version: {{ version || "unknown" }}
```
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.
Go to Custom definitions [#go-to-custom-definitions]
In the property settings column, click **Custom definitions**.
Create a custom dimension [#create-a-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` |
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