DEVELOPER DOCS
JavaScript bridge: native features from your website
Updated November 4, 2026
Prompt for Lovable, Bolt or Base44
Add support for the SaasToStore app bridge to my project.
Context: my website also runs inside a native mobile app built with SaasToStore. Inside that app, a global object `window.saastostore` gives access to native features. In a normal web browser this object does not exist, and the site must keep working exactly as it does today. Do not change anything else.
1. Create `src/lib/saastostore.ts` with:
- `isInApp()`: returns `typeof window !== "undefined" && navigator.userAgent.includes("SaasToStoreApp")`.
- `whenSaasToStore(key, timeoutMs = 3000)`: resolves `window.saastostore?.[key]` as soon as it exists (check every 100 ms), or `null` after the timeout. The bridge only loads once the page has finished loading.
- TypeScript types for `window.saastostore`, every member optional:
- `biometric.isAvailable(): Promise<{ available: boolean; type: string } | null>`
- `biometric.authenticate(reason?: string): Promise<{ success: boolean; error: string | null } | null>`
- `setTabBadge(index: number, count: number): void`
- `billing: { isAvailable: boolean; identify(userId: string): void; purchase(productId: string): void; restore(): void; getStatus(): void }`
- `type PluginResult = { ok: boolean; error?: string } | null`
- `haptics: { impact(o?: { style?: "light" | "medium" | "heavy" }): Promise<PluginResult>; notification(o?: { type?: "success" | "warning" | "error" }): Promise<PluginResult>; selection(): Promise<PluginResult>; vibrate(o?: { duration?: number }): Promise<PluginResult> }`
- `appReview: { request(): Promise<{ requested: boolean; error?: string } | null> }`
- `share(o?: { title?: string; text?: string; url?: string }): Promise<{ shared: boolean; error?: string } | null>` (a function, not an object; with neither text nor url it shares the page shown; url must be http or https)
- `screen: { keepAwake(on?: boolean): Promise<PluginResult>; setBrightness(value?: number | null): Promise<PluginResult>; getBrightness(): Promise<{ brightness: number | null; error?: string } | null> }`
- `secureStorage: { get(key: string): Promise<{ value: string | null; error?: string } | null>; set(key: string, value: string): Promise<PluginResult>; remove(key: string): Promise<PluginResult>; keys(): Promise<{ keys: string[]; error?: string } | null>; clear(): Promise<PluginResult> }` (key: letters, digits, ".", "_", "-", 1 to 128 characters, e.g. "auth.refresh_token", never "auth:token"; value: a string of 64 KB at most, JSON.stringify objects; anything else resolves to { ok: false, error: "invalid_argument" }, nothing is converted)
- `appInfo: { get(): Promise<{ platform: "android" | "ios"; version: string; build: string; packageName: string; osVersion: string; language: string; model: string } | null> }`
- `browser: { open(url: string): Promise<PluginResult> }`
- `statusBar: { setStyle(style: "light" | "dark"): Promise<PluginResult>; setColor(hex: string): Promise<PluginResult> }` (setColor only works up to Android 14: call it with the page background together with setStyle, and ignore its not_supported)
- `localNotifications: { checkPermission(): Promise<{ status: "granted" | "denied" | "prompt" } | null>; requestPermission(): Promise<{ granted: boolean; status: "granted" | "denied" | "prompt"; error?: string } | null>; schedule(o: { id?: number; title: string; body?: string; at?: number | string; in?: number; data?: object }): Promise<{ scheduled: true; id: number; at: number } | { scheduled: false; error: string; status?: "denied" | "prompt" } | null>; cancel(o: { id: number }): Promise<PluginResult>; cancelAll(): Promise<PluginResult>; getPending(): Promise<{ notifications: { id: number; title: string; body: string; at: number; data: object }[] } | null>; getLastOpened(): Promise<{ notification: { id: number; title: string; body: string; at: number; data: object } | null } | null> }` (schedule() needs the permission: without it { scheduled: false, error: "not_allowed", status }; at: epoch ms or ISO 8601 with a time zone, or in: seconds, never both; title 1 to 200 characters, body 1,000 at most, data a JSON object of 4 KB at most; a tap dispatches a "s2s:localNotifications" CustomEvent on window with detail { type: "tap", id, title, body, at, data }; call getLastOpened() when the page starts: it returns the notification that opened the app, once)
- `barcode: { isAvailable(): Promise<{ available: boolean; reason?: "no_play_services" | "module_not_installed" | "no_camera" } | null>; checkPermission(): Promise<{ status: "granted" | "denied" | "prompt" } | null>; requestPermission(): Promise<{ granted: boolean; status: "granted" | "denied" | "prompt"; error?: string } | null>; scan(o?: { formats?: string[]; prompt?: string }): Promise<{ scanned: true; value: string; format: string } | { scanned: false; error: string; reason?: string } | null> }` (formats among qr, ean13, ean8, upca, upce, code128, code39, code93, itf, codabar, datamatrix, pdf417, aztec, all by default; prompt 200 characters at most, shown on iOS only; Android: Google Play services reader, no camera permission; iOS: the app's own reader after the camera permission)
- `downloads: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; download(o: { url: string; filename?: string; open?: boolean }): Promise<{ downloaded: boolean; id?: string; filename?: string; mimeType?: string; size?: number; error?: string; reason?: string } | null>; open(o: { id: string }): Promise<{ opened: boolean; error?: string } | null> }` (normal download links need no code; the window event `s2s:downloads` carries `{ state, id, filename, source, error? }`)
- `calendar: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; addEvent(o: { title: string; start: string | number | Date; end?: string | number | Date; allDay?: boolean; location?: string; notes?: string }): Promise<{ ok: true; saved: boolean | null } | { ok: false; error: string } | null> }` (opens the phone's calendar editor, filled in: the user saves the event, the site reads nothing; start and end: ISO 8601 with a time zone, epoch ms or a Date, end defaults to one hour after start; with allDay: true, "YYYY-MM-DD" days, end = the last day, included; title 1 to 500 characters, location 1,000 at most, notes 8,000; saved is null on Android, true or false on iOS)
- `documentScanner: { isAvailable(): Promise<{ available: boolean; error?: string; reason?: "no_play_services" | "unsupported_device" | "module_not_installed" } | null>; scan(o?: { pages?: number; format?: "jpeg" | "pdf" }): Promise<{ ok: true; format: "jpeg" | "pdf"; pageCount: number; truncated: boolean; files: File[] } | { ok: false; error: string; reason?: string } | null> }` (opens the phone's document scanner: edges found, page straightened; pages 1 to 20, 10 by default; jpeg: one File per page, pdf: a single File, ready for a FormData upload; 32 MB at most per scan, otherwise too_large; Android: Google Play services scanner, no camera permission; iOS: asks for the camera the first time, refused → not_allowed)
- `contacts: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; pick(o?: { field?: "phone" | "email" | "name" }): Promise<{ ok: true; contact: { name: string | null; phones: string[]; emails: string[] } } | { ok: false; error: string } | null> }` (opens the phone's own contact picker: the user chooses one contact and, for "phone" or "email", one value, returned alone in phones or emails; field defaults to "phone"; the site never reads the address book, no permission)
Billing results arrive as a `window` event named `"s2s:billing"`; `event.detail.type` is `identify_result`, `purchase_result`, `restore_result` or `status`.
2. Biometric lock: in the settings page, show an "Unlock with fingerprint / Face ID" switch only when `isInApp()` and `biometric.isAvailable()` returns `available: true`. Save the choice in localStorage. When it is on, hide the signed-in content when the app opens or comes back to the foreground (`visibilitychange`), call `biometric.authenticate("Unlock [APP NAME]")`, and show the content again only when `success` is true, with a "Try again" button otherwise.
3. Tab badge: whenever my unread count changes, call `window.saastostore?.setTabBadge?.([TAB INDEX], count)` (0 hides the badge).
4. Subscriptions in the Android app: when `isInApp()` and `billing.isAvailable` is true, call `billing.identify(user.id)` after sign-in and `billing.getStatus()` after the `identify_result` event. Make my upgrade button call `billing.purchase("[PRODUCT ID]")` there instead of the web checkout, listen to `s2s:billing`, and unlock premium when a `purchase_result` or `status` event has `isEntitled: true`. Inside the iOS app (`isInApp()` and `/iPhone|iPad/.test(navigator.userAgent)`), hide the upgrade button. In a normal browser, keep the current payment flow.
5. Native plugins: only for the plugins I added in SaasToStore (delete the other lines). Call each one optionally (`window.saastostore?.haptics?.…`); its promise never rejects.
- Haptic feedback: call `haptics.impact({ style: "light" })` when a main button is tapped, and `haptics.notification({ type: "success" })` or `({ type: "error" })` after a form succeeds or fails.
- In-app review: call `appReview.request()` once, after [A SUCCESS MOMENT], never from a button.
- Native share: inside the app, make my share buttons call `share({ title, text, url })` instead of the web share code.
- Screen control: on [PAGE WITH A QR CODE OR TICKET], call `screen.keepAwake(true)` and `screen.setBrightness(1)` when it opens, then `screen.setBrightness(null)` and `screen.keepAwake(false)` when it closes.
- Secure storage: inside the app, also save [VALUE TO PROTECT] with `secureStorage.set(key, value)` (key: letters, digits, `.`, `_`, `-` only, e.g. `auth.refresh_token`; value: a string, `JSON.stringify` objects; never pass `undefined`), read it with `secureStorage.get(key)`, and call `secureStorage.clear()` at sign-out.
- App info: show the `version` and `build` returned by `appInfo.get()` in the settings page.
- In-app browser: inside the app, open links to other domains (not sign-in links) with `browser.open(url)` instead of a new tab.
- Status bar: on dark pages call `statusBar.setColor(PAGE BACKGROUND)` then `statusBar.setStyle("dark")`, on light pages `statusBar.setColor(PAGE BACKGROUND)` then `statusBar.setStyle("light")` (setColor only works up to Android 14; ignore its `not_supported`).
- Screen orientation: nothing to code, it is a build setting.
- QR and barcode scanner: on [THE BUTTON THAT SCANS], call `barcode.scan({ formats: ["qr"], prompt: "Scan your code" })` and use `value` when `scanned` is true; ignore `error: "cancelled"`, show a message for the other errors; hide the button when `barcode.isAvailable()` answers `available: false`.
- File downloads: my download links work in the app with no code. Only for [THE BUTTON THAT EXPORTS A FILE], inside the app, call `downloads.download({ url, filename })`, and show a message when `downloaded` is false.
- Calendar: on [THE BUTTON THAT ADDS AN EVENT], inside the app, call `calendar.addEvent({ title, start, end, location, notes })` with `start` and `end` as `date.toISOString()` (or `{ title, start: "YYYY-MM-DD", end: "YYYY-MM-DD", allDay: true }` for whole days, `end` included); show a message when `ok` is false; hide the button when `calendar.isAvailable()` answers `available: false`; outside the app, the button keeps doing what it does today.
- Document scanner: on [THE BUTTON THAT SCANS A DOCUMENT], inside the app, call `documentScanner.scan({ format: "pdf" })` and upload `files` like a file chosen in my form; do nothing when `error` is `"cancelled"`, show a message for the other errors; hide the button when `documentScanner.isAvailable()` answers `available: false`; outside the app, keep my file input.
- Contacts: on [THE BUTTON NEXT TO THE PHONE FIELD], inside the app, call `contacts.pick({ field: "phone" })` (or `"email"`) and put `contact.phones[0]` (or `contact.emails[0]`) in the field when the list is not empty; do nothing when `error` is `"cancelled"`; outside the app, hide that button and keep the field as it is.
- Local notifications: on [ACTION THAT DESERVES A REMINDER], call `localNotifications.requestPermission()` first (once, from that button, never at launch), then `localNotifications.schedule({ id, title, body, at })` with `at` as `Date.now() + …` or `date.toISOString()`; listen to `window.addEventListener("s2s:localNotifications", e => …)` and call `localNotifications.getLastOpened()` when the page starts, to open [THE PAGE OF THE REMINDER]; call `localNotifications.cancel({ id })` when the reminder is no longer needed.Before pasting, delete the steps you don't need and replace the values in [BRACKETS]. Then rebuild your app on SaasToStore if you turned on a new native feature.
Detect the app
Every call below only makes sense inside the app. Check first, and your site keeps working unchanged in a normal browser.
The user agent carries “SaasToStoreApp”
Android apps built since July 2026 and iOS apps built since 19 October 2026 add it to every request and to navigator.userAgent:
const inApp = navigator.userAgent.includes("SaasToStoreApp");Use includes(), never an exact match
The marker is appended after the system user agent, with spacing that varies by platform. Your server can read it too, in the User-Agent header.
Then check the API itself
window.saastostore only exists inside the app, and each feature only when it is turned on. Test the member you need, for example window.saastostore?.biometric, before calling it.
What works where
Tab badges, navigation and in-app purchases come with Shell V2, the native shell (header, tab bar, side menu) you turn on in the Shell Designer of your project; the plugins of the library work with or without it (on iOS, the status bar only in full-screen apps). Biometrics too, except that in an iOS app without Shell V2 it also needs one of these plugins: Haptic feedback, In-app review, Native share, Screen control, Secure storage, App info, In-app browser, Status bar or Screen orientation. Native features are added when the app is built: after turning one on, launch a new build. Changes to your website show up in the app right away; native changes don't. None of this exists in the Android Build (Web) (TWA), which opens your site in Chrome: choose the Android Build (Native) or the Android TV build.
| Feature | Android app (native build) | iOS app |
|---|---|---|
| Detect the app (user agent) | Builds since July 2026 | Builds since 19 Oct 2026 |
| Biometric unlock (biometric) | Builds since 28 Oct 2026, with or without Shell V2 | With Shell V2; without it, once one of these plugins is added: haptics, appReview, share, screen, secureStorage, appInfo, browser, statusBar or orientation (new build) |
| Library plugins (haptics, share, screen…) | Once added, from your next build, with or without Shell V2 | Once added, from your next build, with or without Shell V2 (status bar: full-screen apps only) |
| Tab badges (setTabBadge) | Shell V2 with a tab bar | Shell V2 with a tab bar |
| Active tab and header title | Shell V2 | Shell V2 |
| In-app purchases (billing) | Shell V2 + In-App Subscriptions (Google Play) | Not available yet |
Wait for the bridge
The bridge loads once your page has finished loading, so it may be missing during the very first lines of your code. Call it from a user action, or wait for it with this helper. It resolves the feature, or null outside the app. The plugins of the library are there from the first line of your page.
whenSaasToStore(key)
function whenSaasToStore(key, timeoutMs = 3000) { return new Promise((resolve) => { const start = Date.now(); (function check() { const api = window.saastostore?.[key]; if (api || Date.now() - start > timeoutMs) return resolve(api ?? null); setTimeout(check, 100); })(); }); }
Biometric unlock
Asks the person holding the phone to prove it is them: fingerprint, face, or the phone's PIN as a fallback. It does not sign anyone in: use it to unlock a session your site already has, or to confirm a sensitive action. Turn on Biometric unlock in the plugin library of your project (it is on by default), then rebuild. On iOS it also works without Shell V2, as long as one of these plugins is also added: Haptic feedback, In-app review, Native share, Screen control, Secure storage, App info, In-app browser, Status bar or Screen orientation. The app gets it with its next build.
isAvailable()
Is a fingerprint or a face enrolled on this phone? type is fingerprint, face, iris or biometric on Android, faceid or touchid on iOS.
await window.saastostore.biometric.isAvailable(); // → { available: true, type: "fingerprint" } // → { available: false, type: "none" }authenticate(reason)
Opens the system window with your reason under the app name. error is the system message when the person cancels. On Android, it can also be not_available (no biometrics and no screen lock) or busy (a window is already open).
await window.saastostore.biometric.authenticate("Unlock your account"); // → { success: true, error: null } // → { success: false, error: "Authentication canceled" }Example
const bio = await whenSaasToStore("biometric"); const check = await bio?.isAvailable(); if (check?.available) { const result = await bio.authenticate("Unlock your account"); if (result?.success) showAccount(); }
Plugins from the library
The plugins below are off until you add them in the plugin library of your project; then launch a new build. They work in the native Android builds (Android Build (Native), Android TV) and in iOS apps, with or without Shell V2 (on iOS, the status bar only in full-screen apps). They are not in the Android Build (Web) (TWA): there, window.saastostore does not exist.
Ready from the first line
Their API is set before your page's first script runs, on your app's domain and its subdomains, in the main frame only: never in an iframe or on another site.
Promises that never reject
Every method returns a Promise that never rejects. When the call fails, it resolves to an object with an error field: not_available, not_supported, invalid_argument, busy, timeout or failed; and to null if the app cannot answer. Outside the app, or in an app built before you added the plugin, the plugin is simply missing: check it before calling it.
const haptics = window.saastostore?.haptics; // undefined: normal browser, plugin not added, or app built before you added it if (haptics) { const r = await haptics.impact({ style: "light" }); if (r?.error) console.warn("haptics:", r.error); // never throws }
Haptic feedback
Short vibrations that confirm a tap, a success or an error. What the person feels depends on their phone and on its haptic feedback setting.
impact, notification, selection, vibrate
impact takes a style (light, medium by default, heavy), notification a type (success by default, warning, error); selection is a light tick for a picker. vibrate takes a duration from 1 to 1000 ms (300 by default); iOS cannot set a duration and plays a strong impact instead. A duration that is not a number from 1 to 1000 answers invalid_argument. On Android, impact, notification, selection and vibrate answer not_available when the device plays nothing (no vibrator, as on most tablets and TVs, or haptic feedback turned off); iOS answers ok: true in that case.
const haptics = window.saastostore?.haptics; await haptics?.impact({ style: "medium" }); // light | medium | heavy await haptics?.notification({ type: "success" }); // success | warning | error await haptics?.selection(); // a light tick await haptics?.vibrate({ duration: 200 }); // ms, 1 to 1000 // → { ok: true } // → { ok: false, error: "invalid_argument" } e.g. duration: 5000 // → { ok: false, error: "not_available" } Android: the device plays nothing
In-app review
Opens the store's rating window without leaving the app. The store decides whether it appears and limits how often; your code is never told whether a rating was left. It only appears in an app installed from Google Play or the App Store, never in TestFlight.
request()
Call it after a success (an order delivered, a goal reached), not as the only action of a “Rate us” button: the window may not show. error can be not_available (no Google Play on the device), busy (a request is already running), timeout or failed.requested: true comes back as soon as the request is handed to the store, without waiting for the window to close. On Android, a second request while the first window is still open answers busy.
// after a success, never as the only action of a button const r = await window.saastostore?.appReview?.request(); // → { requested: true } handed to the store, which decides whether to show it // → { requested: false, error: "not_available" } // → { requested: false, error: "busy" } Android: a review window is still open
Screen control
Keep the screen on and set its brightness, for a recipe, a boarding pass or a QR code at a gate. The settings last while the app is open.
keepAwake(on)
true keeps the screen on while the app is in front; false gives control back to the phone.
setBrightness(value), getBrightness()
value goes from 0 to 1; null returns to the system brightness. On Android it only changes the app's window. On iOS it changes the whole screen: the app puts the original brightness back when it goes to the background, and applies yours again when it returns. Values are checked, never converted: setBrightness takes null or a number from 0 to 1, keepAwake a boolean (no argument means true); anything else answers invalid_argument. On Android, until the app sets a brightness, getBrightness returns an approximation of the system setting.
const screen = window.saastostore?.screen; await screen?.keepAwake(true); // → { ok: true } await screen?.setBrightness(1); // 0 to 1, e.g. to show a QR code await screen?.getBrightness(); // → { brightness: 1 } await screen?.setBrightness(null); // back to the system brightness await screen?.keepAwake(false); await screen?.setBrightness("1"); // → { ok: false, error: "invalid_argument" }
Secure storage
A small key-value store encrypted by the phone, for a token or a secret your site needs from one launch to the next. On Android, values are encrypted with AES-256-GCM using a key from the Android Keystore that never leaves the device. On iOS, they go to the Keychain, readable by this app only, on this device only, after its first unlock.
get, set, remove, keys, clear
Keys use letters, digits, dot, underscore and hyphen (1 to 128 characters); values are strings up to 64 KB: store objects with JSON.stringify. Nothing is converted for you: a key in another format (such as auth:token), a value that is not a string (undefined, a number, an object) or a longer one answers invalid_argument, and nothing is saved.
const vault = window.saastostore?.secureStorage; await vault?.set("refresh_token", token); // → { ok: true } const saved = await vault?.get("refresh_token"); // → { value: "…" }, or { value: null } when the key is missing await vault?.keys(); // → { keys: ["refresh_token"] } await vault?.remove("refresh_token"); // → { ok: true } await vault?.clear(); // e.g. at sign-out await vault?.set("session", JSON.stringify({ id, exp })); // objects: JSON.stringify await vault?.set("auth:token", undefined); // → { ok: false, error: "invalid_argument" } ":" in the key, value not a stringLimits to know
On iOS, the Keychain survives uninstalling the app: a reinstall finds the old values. Call clear() at sign-out, and don't take an empty store for a first install. On Android, everything is erased with the app and never backed up: a reinstall, or a new phone, starts empty. Each site has its own store, like localStorage: www.example.com and example.com share one, help.example.com has another, and keys() and clear() only see the calling site's keys. Your page's JavaScript can read the values: they are protected on the phone, not against a script injected into your site.
App info
The version and build number of the app, the platform, the system version and the language of the phone: for your settings page or a support message.
get()
build is a string on both platforms. model is a generic model name (the model on Android, “iPhone” on iOS): no device identifier is returned.
await window.saastostore?.appInfo?.get(); // → { platform: "android", version: "1.4.0", build: "12", // packageName: "com.example.app", osVersion: "14", // language: "fr-FR", model: "Pixel 8" }
In-app browser
Opens a page in a browser tab on top of the app: Custom Tabs on Android (or the default browser), Safari View Controller on iOS. Use it for pages outside your domain: terms, help, a partner's site.
open(url)
http and https only. The page runs outside your app: it gets neither window.saastostore, nor the SaasToStoreApp user agent, nor your app's session. Leave sign-in with Google or Apple to the app, which already handles it. error can be invalid_argument, busy (iOS: another sheet is open), not_available or failed. On Android, a link to a site that has its own app on the phone (a video or social network site, or your own domain when deep links are on) can open that app instead of a tab.
const r = await window.saastostore?.browser?.open("https://example.com/terms"); // → { ok: true } // → { ok: false, error: "invalid_argument" } not http or https
Status bar
Match the status bar to your page: dark icons on a light page, light icons on a dark page. On Android 14 and earlier, the status bar is a band above the page, not the page itself: call setColor with your page's background color together with setStyle. On Android 15 and later and on iOS, the page runs under the status bar: setStyle is enough, and the not_supported answer of setColor can be ignored.
setStyle(style)
"light" for a light page (dark icons), "dark" for a dark page (light icons). On iOS with Shell V2, the answer is not_supported: the status bar follows the native header.
setColor(hex)
Background color as #RRGGBB, on Android 14 and earlier only. Android 15 and later draw apps edge to edge, and iOS has no status bar color: the call answers not_supported there.
const bar = window.saastostore?.statusBar; // a light page: dark icons, and (Android 14 and earlier) a bar of the page color await bar?.setColor("#ffffff"); // → { ok: true } up to Android 14 await bar?.setStyle("light"); // → { ok: true } // a dark page: light icons await bar?.setColor("#101827"); await bar?.setStyle("dark"); // setColor → { ok: false, error: "not_supported" } Android 15+ and iOS: ignore it // setStyle → { ok: false, error: "not_supported" } iOS with Shell V2
Screen orientation
A build setting, no JavaScript: add Screen orientation in the plugin library, choose Portrait or Landscape, then rebuild. The app stays in that orientation (on Android, it can still turn upside down).
- Android 16 ignores the lock on large screens (600 dp and wider: tablets, unfolded foldables) for apps that target API 36, which SaasToStore builds do: keep your layout responsive.
- The portrait lock is not applied to the Android TV build, which stays in landscape.
Local notifications
Reminders scheduled on the phone by your page, shown at the chosen time even when the app is closed: an appointment, a session that starts, a cart left behind. Nothing leaves your site and no server is involved: this is not push. The notification opens your app, and your page learns which one was tapped.
requestPermission() and checkPermission()
Ask once, from a user action (a “Remind me” button), never at launch: Android 13 and later and iOS show a system window (older Android versions answer granted). The answer is { granted, status }, with status granted, denied or prompt. After a refusal, the system does not ask again: send the user to the app settings. checkPermission() only reads the state.
schedule({ title, body, at | in, id, data })
at is a date (milliseconds, or ISO 8601 with a time zone, as toISOString() gives it) or in a delay in seconds, never both; with neither, the notification shows now. The answer is { scheduled: true, id, at }. Limits: title 200 characters, body 1,000, data a JSON object of 4 KB, 64 pending reminders, 366 days ahead; a past date shows now; the id of a pending reminder replaces it. Without the permission, { scheduled: false, error: "not_allowed", status }: ask first. Otherwise invalid_argument, too_many or failed.
const notif = window.saastostore?.localNotifications; // ask once, from a "Remind me" button, never at launch const p = await notif?.requestPermission(); // { granted: true, status: "granted" } const r = await notif?.schedule({ id: 42, // optional: the same id replaces a pending reminder title: "Your session starts in 10 minutes", body: "Tap to open the room", at: Date.now() + 50 * 60 * 1000, // or in: 3000 (seconds), or an ISO 8601 date data: { roomId: "a1" }, }); // r → { scheduled: true, id: 42, at: 1762000000000 } // without the permission: { scheduled: false, error: "not_allowed", status: "prompt" } window.addEventListener("s2s:localNotifications", (e) => { if (e.detail.type === "tap") openRoom(e.detail.data.roomId); }); const opened = await notif?.getLastOpened(); // { notification } once, then { notification: null } await notif?.cancel({ id: 42 });Tap, pending list, cancel
A tap opens the app on your page, which receives a s2s:localNotifications event on window (detail.type "tap", with id, title, body, at, data). If the app was closed, the event fires before your page listens: call getLastOpened() when the page starts, it answers the notification once, then null. getPending() lists the reminders to come; cancel({ id }) and cancelAll() remove them, shown ones included.
Time and restarts
Android shows the reminder within a few minutes of the time (inexact alarm, no “Alarms & reminders” permission): right for an appointment, not for a countdown to the second. iOS is on time. Reminders survive a restart of the phone: Android reschedules them at boot, iOS keeps them. On Android, the notification carries your app icon and a “Notifications” channel the user can silence in the system settings.
QR and barcode scanner
Read a QR code or a barcode with the camera, from your page: a ticket, a product, a link to open. On Android the reader is Google's (Google Play services), full screen with auto-zoom, and your app asks for no camera permission. On iOS the app opens its own reader, after the camera permission.
isAvailable()
{ available: true }, or { available: false, reason }: no_play_services (Android without Google Play services: Amazon Fire, Huawei), module_not_installed (Android: Google Play downloads the reader once; the download is requested, try again later), no_camera (iOS). Check it before showing a scan button.
scan({ formats, prompt })
Opens the reader and resolves with the first code read: { scanned: true, value, format }. formats limits what is read (all by default): qr, ean13, ean8, upca, upce, code128, code39, code93, itf, codabar, datamatrix, pdf417, aztec. prompt is the text shown above the viewfinder on iOS (200 characters at most; Android shows Google's own interface). Closed by the user: { scanned: false, error: "cancelled" }; otherwise not_available, not_allowed, busy, invalid_argument or failed.
const scanner = window.saastostore?.barcode; const ok = await scanner?.isAvailable(); // { available: true } or { available: false, reason: "no_play_services" | "module_not_installed" | "no_camera" } const r = await scanner?.scan({ formats: ["qr"], prompt: "Scan your ticket" }); if (r?.scanned) openTicket(r.value); // { scanned: true, value: "...", format: "qr" } else if (r?.error !== "cancelled") showMessage(r?.error); // not_available, not_allowed, busy, invalid_argument, failedPermission
Android: none, checkPermission() always answers granted. iOS: scan() asks for the camera the first time; checkPermission() and requestPermission() let you ask from a button of your own, with status granted, denied or prompt. After a refusal, iOS does not ask again: send the user to the app settings.
File downloads
The download links of your site work in the app, with no code on your side: a link with the download attribute, a file sent as an attachment, an export built in the page (blob:, data:). A file behind a sign-in works too: the app sends its cookies. On Android the file goes to the phone's Downloads folder, with the system notification. iOS has no such folder: the app opens the system preview, where the user shares the file or saves it to Files.
Links
Nothing to code for links on your domain. A PDF, or a file type the app cannot display, is treated as a download too. A link to another domain keeps opening the browser. On Android, a file produced by a POST request is not handled: serve it from a GET link or build it in the page. On Android 9 and earlier, the file stays in the app's own folder.
download({ url, filename, open })
Starts a download from your code and resolves when it ends: { downloaded: true, id, filename, mimeType, size }. url: http, https, blob: or data:, relative or absolute. filename replaces the name, which the app cleans up. open: true opens the file at the end. On failure, { downloaded: false, error }: invalid_argument, not_available, busy (5 downloads at once) or failed with a reason such as http_404 or too_large (100 MB at most for blob: and data:).
// Links need no code. From your own code: const files = window.saastostore?.downloads; const r = await files?.download({ url: "/invoices/42.pdf", filename: "invoice-42.pdf" }); if (r?.downloaded) await files.open({ id: r.id }); // { downloaded: true, id, filename, mimeType, size } else if (r) showMessage(r.reason ?? r.error); // invalid_argument, not_available, busy, failed window.addEventListener("s2s:downloads", (e) => { console.log(e.detail); // { state: "started" | "completed" | "failed", id, filename, source: "link" | "api", error? } });open({ id })
Opens a file downloaded since the app started, with the id given by download() or by the event: { opened: true }. Otherwise not_found, no_app (Android: no app opens this type), busy (iOS: a sheet is already open), invalid_argument or failed.
Event
s2s:downloads on window, for links and for download(): e.detail = { state, id, filename, source, error }, with state started, completed or failed, and source link or api. Use it to show that a tapped link is downloading. isAvailable() answers { available: true }.
Calendar
Add an event from your page to the phone's calendar: a booking, a class, a webinar. The app opens the calendar's own editor, filled in with the title, dates, place and notes you give; the user checks it and saves it. Your site never reads or writes the calendar. On Android and on iOS 17 and later the app asks for no permission; on iOS 15 and 16, iOS asks for calendar access the first time.
addEvent({ title, start, end, location, notes })
Opens the editor. title is required (500 characters at most). start and end: an ISO 8601 date and time with its time zone, epoch milliseconds or a Date; without end, the event lasts one hour. location (1,000 characters at most) and notes (8,000) are optional. Android answers { ok: true, saved: null } as soon as the editor opens: it does not say whether the event was saved. iOS answers when the editor closes, with saved true or false.
const cal = window.saastostore?.calendar; // from a button, never at launch const r = await cal?.addEvent({ title: "Product demo", start: "2026-11-20T09:00:00+01:00", // ISO 8601 with a time zone, epoch ms or a Date end: "2026-11-20T10:30:00+01:00", // optional: one hour after start location: "Lyon", notes: "Room 4, bring your badge", }); // Android → { ok: true, saved: null } (Android does not say whether it was saved) // iOS → { ok: true, saved: true | false } when the editor closes if (r && !r.ok) showMessage(r.error); // invalid_argument, not_available, busy, not_allowed // all day, 20 to 22 November (end included): await cal?.addEvent({ title: "Trade fair", start: "2026-11-20", end: "2026-11-22", allDay: true });All-day events
allDay: true, with days as start and end: "2026-11-20", or a date whose local day is taken. end is the last day, included; without it, the event lasts one day. Without allDay, a date with no time is refused: give a time and a time zone.
Errors
{ ok: false, error }: invalid_argument (no title, a date that cannot be read, an end before the start), not_available (no calendar app on the phone), busy (iOS: an editor is already open) or not_allowed (iOS 15 and 16: calendar access refused; send the user to the app settings). isAvailable() answers { available: false } on an Android phone with no calendar app: hide your button then. No repeat and no reminder in this version.
Document scanner
Scan paper documents from your page: an ID card, a receipt, a signed form. The phone's own scanner opens, finds the edges of the page and straightens it; the user can retake a page before confirming. Your page receives the pages as JPEG images or as one PDF, in File objects ready to upload. On Android the scanner belongs to Google Play services and your app asks for no camera permission; on iOS it is Apple's scanner, and iOS asks for the camera the first time.
scan({ pages, format })
Opens the scanner and resolves with { ok: true, format, pageCount, truncated, files }. format: jpeg (default), one file per page (scan-1.jpg, scan-2.jpg…), or pdf, a single scan.pdf. pages: 1 to 20, 10 by default. On Android the scanner stops at that number; on iOS the user can scan more, the first pages are kept and truncated is true. 32 MB at most per scan, otherwise too_large.
const scanner = window.saastostore?.documentScanner; const ok = await scanner?.isAvailable(); // { available: true } or { available: false, reason } // from a button: the phone's own scanner opens const r = await scanner?.scan({ pages: 5, format: "pdf" }); // pages 1 to 20 (10 by default), "jpeg" (default) or "pdf" if (r?.ok) { const body = new FormData(); r.files.forEach((f) => body.append("scan", f)); // File objects: scan.pdf, or scan-1.jpg, scan-2.jpg… await fetch("/api/documents", { method: "POST", body }); } else if (r && r.error !== "cancelled") showMessage(r.error); // busy, not_allowed, not_available, too_large, invalid_argument, failedisAvailable()
{ available: true }, or { available: false, reason }: no_play_services (Android without Google Play services: Amazon Fire, Huawei), unsupported_device (Android with less than 1.7 GB of memory, or an iOS simulator), module_not_installed (Android: Google Play downloads the scanner once; the download is requested, try again later). Check it before showing a scan button.
Errors
{ ok: false, error }: cancelled (closed with no page), busy (a scan is already open), not_allowed (iOS: camera access refused; send the user to the app settings), not_available, too_large, invalid_argument (pages outside 1 to 20, a format other than jpeg or pdf) or failed. On Android the scanner only takes photos: importing from the gallery is off.
Contacts
Let the user fill a field from their contacts: a phone number to invite a friend, an email address to share a file. The app opens the phone's own contact picker; the user chooses one contact, then one number or one address. Your site gets that choice and nothing else: the app never reads the address book and asks for no permission, on Android as on iOS.
pick({ field })
Opens the picker and resolves with { ok: true, contact: { name, phones, emails } }. field says what comes back: phone (default), the number chosen, in phones; email, the address chosen, in emails; name, the name alone. Only contacts with a number (or an address) can be chosen. name is null for a contact with no name and is cut at 500 characters; the list stays empty when the value chosen is empty or longer than 320 characters: check it before using it.
const contacts = window.saastostore?.contacts; // from a button: the phone's own contact picker opens const r = await contacts?.pick({ field: "phone" }); // "phone" (default), "email" or "name" if (r?.ok && r.contact.phones.length) phoneInput.value = r.contact.phones[0]; // { name: "Ada Lovelace", phones: ["+33 6 12 34 56 78"], emails: [] } else if (r && r.error !== "cancelled") showMessage(r.error); // busy, not_available, invalid_argument, failedErrors
{ ok: false, error }: cancelled (closed by the user), busy (a picker is already open), not_available (Android: no contacts app on the phone), invalid_argument (a field other than phone, email or name) or failed. isAvailable() always answers { available: true } on iOS; on Android, { available: false } when the phone has no contacts app: hide your button then.
Tab badges
Show a badge on a native tab, for unread messages or a cart count. Turn on Notification badge for that tab in the Shell Designer.
setTabBadge(index, count)
index starts at 0, in the order of your tabs. A count above 0 shows the badge, 0 hides it. Depending on the tab bar style, the badge shows the number or a dot.
window.saastostore?.setTabBadge?.(2, unreadCount); // 3rd tab, 0 hides it
Navigation and the active tab
The native shell follows your site by itself: every change of address (links, history.pushState, the back button, hash changes) highlights the tab whose URL regex matches, and the header shows your page's document.title. Nothing to code for single-page apps built with React, Vue or Next.js.
navigateTo(path)
What the native tabs and side menu call. A path loads that page, a path starting with # changes the hash, a full https address opens it.
window.saastostore?.navigateTo?.("/cart"); // loads /cart window.saastostore?.navigateTo?.("#/cart"); // hash routing (Bubble, Glide)
In-app purchases (Android)
Digital goods sold inside an Android app must go through Google Play billing. The bridge sells your subscriptions through it, via RevenueCat. Turn on In-App Subscriptions (Google Play) in the plugin library with your RevenueCat Android key (goog_…) and entitlement, keep Shell V2 on, then rebuild. The iOS app does not have it yet: hide your upgrade button there.
Calls
billing.identify(userId); // after sign-in, before any purchase billing.getStatus(); // is the subscription active? billing.purchase(productId); // opens the Google Play purchase sheet billing.restore(); // restore purchasesResults: the “s2s:billing” event
Each call answers with an event on window; event.detail.type tells which one. An error of cancelled means the person closed the purchase sheet.
identify_result { success, userId, error? } purchase_result { success, productId, isEntitled?, error? } restore_result { success, isEntitled, error? } status { isEntitled, entitlementId, expiresAt? }Example
const billing = await whenSaasToStore("billing"); window.addEventListener("s2s:billing", (e) => { const d = e.detail; if (d.type === "identify_result" && d.success) billing.getStatus(); if (d.type === "status") setPremium(d.isEntitled); if (d.type === "purchase_result" && d.success) setPremium(d.isEntitled); }); if (billing?.isAvailable) billing.identify(currentUser.id); // after sign-in // your "Upgrade" button, inside the Android app: billing.purchase("premium_monthly");
Firebase Analytics
Send your events to Google Analytics for Firebase, on your own Firebase project. Add the plugin, add your Firebase files in its detail, then run a new build. Without these files the plugin stays out of the app: it never runs on a project shared with other apps. Nothing is sent before your site gives its consent (setConsent, below). The app has no advertising ID.
logEvent({ name, params })
Sends one event. name: 40 characters at most, letters, digits and _, starting with a letter, without the prefixes firebase_, google_ and ga_. params: 25 values at most, each a text, a number or a boolean (sent as 1 or 0). A refused name or value answers { ok: false, error: "invalid_argument" }; otherwise { ok: true }.
const analytics = window.saastostore?.firebaseAnalytics; // undefined: normal browser, plugin not added, or app built before you added it if (analytics) { await analytics.logEvent({ name: "purchase", params: { value: 39, currency: "EUR" } }); }setUserId({ id }), setUserProperty({ name, value })
id: your own user ID, 256 characters at most, never an email address. Property name: 24 characters at most, same rules as an event; value: 36 characters at most. null clears the value.
setConsent({ analytics, ads })
Nothing is collected until your site calls this with analytics: true, not even the events Firebase logs on its own at launch. Call it when the visitor accepts, or when your page loads if you need no consent. Firebase keeps the answer on this phone from one launch to the next. Advertising storage and personalization stay refused until ads: true. analytics: false stops all collection again. Asking for consent where the law requires it is up to you.
// once the visitor has accepted (or at page load if you need no consent) await window.saastostore?.firebaseAnalytics?.setConsent({ analytics: true });
Google Tag Manager
Your GTM container runs in the app as it does on the web: your tags fire in the page and nothing changes. If your app has the Firebase Analytics plugin, one more tag copies your events to it.
Custom HTML tag
Create a tag of type Custom HTML with this code:
<script> (function () { var analytics = window.saastostore && window.saastostore.firebaseAnalytics; if (!analytics) return; // normal browser, or app built without the plugin analytics.logEvent({ name: {{Event}}, params: { page_path: {{Page Path}} } }); })(); </script>Trigger
Use a Custom Event trigger that lists the events to copy, for example purchase|sign_up with Use regex matching. Do not use All Pages: GTM then sends names such as gtm.js, which Analytics refuses.
Event names
Up to 40 characters: letters, digits and underscores, starting with a letter. Any other name is refused: the call answers { ok: false, error: "invalid_argument" } and the page is not affected.
In the app only
To fire a tag only in the app, or to keep it out, add a Custom JavaScript variable and use it in the conditions of the trigger:
function () { return navigator.userAgent.indexOf("SaasToStoreApp") !== -1; }Consent
The consent banner of your site does not reach Analytics by itself. Call setConsent from a tag that fires once the visitor has answered, before the first event:
<script> (function () { var analytics = window.saastostore && window.saastostore.firebaseAnalytics; if (analytics) analytics.setConsent({ analytics: true, ads: false }); })(); </script>
JavaScript bridge — frequently asked questions
Do I need to write native code or add an SDK to my site?+
No. The bridge is part of the apps SaasToStore builds. Your website calls it in JavaScript, and you turn features on, or add plugins, in the plugin library of your project.
Does it change my website in a normal browser?+
No. window.saastostore only exists inside the app. In a browser, the checks return nothing and your site behaves exactly as before.
Why is window.saastostore undefined when my page starts?+
The Shell V2 part of the bridge loads once the page has finished loading: call it from a user action, or wait for it with the whenSaasToStore helper on this page. The plugins of the library are there from the first line of your page.
Is biometric unlock a way to sign users in?+
No. It only confirms that the person holding the phone is its owner. Your site keeps its own sign-in; biometrics unlock a session that already exists, or confirm a sensitive action.
What do I need for in-app purchases?+
A Google Play developer account where your subscriptions are created, a RevenueCat project linked to it, and the RevenueCat Android key entered in the plugin library. Then rebuild the app.
My app was built before a feature existed. What do I do?+
Launch a new build. Native features are added when the app is built; updates to your website show up without one.
Do the plugins need Shell V2?+
No. Haptic feedback, in-app review, native share, screen control, secure storage, app info, the in-app browser and screen orientation work with or without Shell V2, on Android and iOS. The status bar works with or without Shell V2 on Android, but on iOS only in full-screen apps: with Shell V2 it follows the native header. Add them in the plugin library, then rebuild the app (Android Build (Native) or iOS, not the Android Build (Web)).
Why does appReview.request() show nothing?+
Google Play and the App Store decide when the rating window appears and limit how often. It only appears in an app installed from the store, never in TestFlight, and your code is never told whether a rating was left.
Are local notifications push notifications?+
No. A local notification is created by your page on the phone itself, at a time you choose, with no server and no message sent: a reminder, an appointment, a cart left behind. It needs the same permission as push on Android 13 and later and on iOS, asked from a user action. A message sent to all your users at once is push, a different plugin.
Does the scanner need the camera permission?+
On Android, no: the reader belongs to Google Play services, so your app declares no camera permission and shows no permission window; it needs Google Play services and, once, the download of the reader module. On iOS, yes: the app opens its own reader and iOS asks for the camera the first time, with the text set by SaasToStore ("This app uses the camera to scan QR codes and barcodes.").
Do download links need code?+
No. Once the plugin is added and the app rebuilt, the download links of your site work as they are, exports built in the page included. Without the plugin, nothing is saved: on Android the tap does nothing; on iOS a zip does nothing, and a PDF or a text file replaces the page with no back button. On Android the file goes to the Downloads folder; on iOS the app opens the system preview, where the user shares it or saves it to Files.
Does the calendar plugin read the user's calendar?+
No. It opens the editor of the phone calendar, filled in with your event, and the user saves it; your site gets nothing back from the calendar. On Android and on iOS 17 and later there is no permission window. On iOS 15 and 16, iOS asks for calendar access the first time, with the text set by SaasToStore ("This app adds events to your calendar when you ask it to.").
Does the contacts plugin read the address book?+
No. The phone's own picker opens, and your site gets only what the user picks: one contact, with one number or one address. There is no permission window, on Android or on iOS, and no list of contacts is ever sent to your site.
Does the document scanner need the camera permission?+
On Android, no: the scanner belongs to Google Play services, so your app declares no camera permission; the phone needs Google Play services, 1.7 GB of memory and, once, the download of the scanner module. On iOS, yes: iOS asks for the camera the first time, with the text set by SaasToStore ("This app uses the camera to scan documents.", or one text for documents and codes when the QR scanner is added too).
Related pages
Turn your website into an app first
Start with a free Android test build, then add the native features you need.
Start a free test