One website, with app-aware moments.
The SDK is an optional progressive enhancement. Your pages still own content, routing, accounts, carts, and business rules. The native shell owns app chrome and device integrations. The SDK passes small, validated messages between the two.
There is no SiteTo.App public REST service involved. Websites do not need a SiteTo.App key, and the SDK must never contain store credentials, customer secrets, or privileged account tokens.
Contextual page titles
Let a checkout, account, or article page update the native header while keeping the browser title correct.
Live navigation badges
Push a known value immediately or read a cart, inbox, or booking count from a same-origin URL.
Automatic active tab
The app highlights the right tab from the current URL by itself, on traditional pages and single-page applications.
Lifecycle events
Refresh stale website state when the app resumes, or respond when a notification is opened.
Native actions
Use the system share sheet, permission prompts, settings, and subtle haptic feedback when supported.
Safe browser behavior
Every feature has a documented browser fallback or no-op so one website codebase continues to work everywhere.
One script, explicitly versioned.
Add the CDN script to an ordinary website, or install the npm package in a bundled application. Both expose the same behavior and protocol version.
<script
src="https://siteto.app/sdk/v1/sitetoapp.min.js"
defer
></script>npm install @sitetoapp/web-sdk
import { SiteToApp } from '@sitetoapp/web-sdk';Major versions live in the URL and package contract. Non-breaking additions can ship within v1; renamed methods, changed payloads, or different fallback behavior require v2.
Detect capabilities, not user agents.
SiteToApp.ready() begins a handshake with the native shell and resolves to an AppContext. It must also resolve in a normal browser, so application code never hangs while waiting for a bridge that is not present.
const context = await SiteToApp.ready({ timeout: 1500 });
if (!context.available) {
// Normal browser: keep using your existing web behavior.
return;
}
await SiteToApp.page.setTitle('Order details');
await SiteToApp.navigation.setBadge('/orders', 2);| Context field | Value | Reason |
|---|---|---|
| available | boolean | Whether a native shell completed a valid handshake. |
| platform | ios | android | web | The active runtime; never inferred from the user agent. |
| appVersion | string | null | The installed native app version, when available. |
| sdkVersion | string | The loaded website SDK version. |
| capabilities | string[] | Supported commands such as navigation.badge or share. |
context.capabilities, because support varies by platform. Platform checks are a last resort.What works where
| Feature | iOS | Android |
|---|---|---|
| Titles, loading state, pull-to-refresh | Yes | Yes |
| Badges, item label and visibility | All menus (header icons show no label) | All menus (header icons show no label) |
| Active item | Bottom tabs, automatic from the URL | Bottom tabs, automatic from the URL |
| Open, back, handleOpen, notification events | Yes | Yes |
| Share sheet, notification settings link | Yes | Yes |
| Lifecycle events | Yes | Yes |
| notifications.requestPermission | Yes | Yes, on Android 13 and later |
| haptics.impact | Yes | Yes |
| context.appVersion | Yes | Yes |
Keep the native header in context.
A page can change the app header as customers move through products, articles, account sections, or checkout. setTitle updates both the header and document.title, so browser history remains useful too.
// Updates the app's header and document.title together.
await SiteToApp.page.setTitle('Your cart');
// Return to your app's name.
await SiteToApp.page.resetTitle();page.setLoading(boolean)Connect a website transition to the app’s loading screen. It hides itself after eight seconds, so a page cannot leave it running forever.
page.setPullToRefresh(boolean)Disable pull-to-refresh for drawing surfaces, maps, or interactions where the gesture conflicts with page behavior.
Show the count that matters now.
A menu item is addressed by the path of its link, so the tab linking to your cart page is '/cart'. Your website already knows its paths, and changing a label or translating it changes nothing. The same link in two menus stays in step. Badges appear on bottom tabs, side menu items, and header icons, and a path no menu links to is rejected with NOT_ALLOWED.
Set a known value
// A menu item is addressed by the path of its link.
// Numbers are formatted by the app: 100 becomes “99+”.
await SiteToApp.navigation.setBadge('/cart', 3);
// Zero, null, or false hides the badge.
await SiteToApp.navigation.setBadge('/cart', 0);
// Short status labels are also allowed.
await SiteToApp.navigation.setBadge('/inbox', 'NEW');Update from a URL
A watcher fetches in the website context—not from a separate SiteTo.App service. Relative, same-origin URLs are recommended because they keep normal site cookies and security rules intact.
const cartBadge = SiteToApp.navigation.watchBadge('/cart', {
url: '/cart.js',
select: (cart) => cart.item_count,
interval: 30_000,
});
// Refresh after a local add-to-cart action.
await cartBadge.refresh();
// Stop fetching when this page no longer owns the badge.
cartBadge.stop();| Option | Default | Behavior |
|---|---|---|
| url | required | HTTPS or relative URL returning JSON or plain text. |
| select | none | Function that picks the badge value from the response. Without it, the response itself is the value. |
| interval | disabled | Polling interval in milliseconds, with a minimum of 15 seconds. It always refreshes on load and when the app resumes. |
| credentials | same-origin | Uses the browser Fetch credentials mode. Cross-origin requests still require CORS. |
- Numeric values display from 1–99; larger values use the native 99+ treatment.
- Text values are trimmed, sanitized, and limited to four visible characters.
- Polling pauses while the app is backgrounded and resumes with one immediate refresh.
- Overlapping requests are cancelled; the newest valid response wins.
- After a failed request the last valid badge stays up, and
sdk:errorfires without breaking the page. Requests time out after five seconds.
Use device behavior where it earns its place.
Native actions should improve an existing web flow, not become a requirement for using the website. Permission prompts must follow a clear user action and explain their value in the page first.
await SiteToApp.share({
title: 'Spring collection',
text: 'These just arrived.',
url: window.location.href,
});
// Must be called from a user click or tap.
const permission = await SiteToApp.notifications.requestPermission();
// Use sparingly for confirmation, not decoration.
await SiteToApp.haptics.impact('light');Share with a useful fallback
Use the native share sheet in the app and navigator.share where available. Otherwise it reports delivered: false, so you can offer your own.
Keep permission status simple
Returns granted, denied, or unsupported. The app also asks once at startup, so a person who declined there needs openSettings(). Notifications start the next time the app opens after they say yes. Push tokens are never exposed to page code.
Open external links deliberately
Internal approved URLs stay in the app. External HTTPS links open in the system browser unless app configuration says otherwise.
Rate-limit physical feedback
Haptics are ignored when unsupported and throttled natively so a page cannot produce repeated disruptive feedback.
Let the website respond to app lifecycle.
Events flow from the app into the SDK after validation. on returns an unsubscribe function so component-based sites can clean up. For connectivity and color scheme, use the browser’s own online events and prefers-color-scheme, which work in the app too.
const unsubscribe = SiteToApp.on('app:resume', () => {
refreshPrices();
});
SiteToApp.on('navigation:reselect', ({ path }) => {
if (path === '/') window.scrollTo({ top: 0, behavior: 'smooth' });
});
// The app opens the notification's page itself. Use this for analytics.
SiteToApp.on('notification:opened', ({ url }) => {
analytics.track('push_opened', { url });
});
// Remove a listener when its owning component unmounts.
unsubscribe();app:resumeThe app returned to the foreground. Refresh time-sensitive page data and watched badges.
app:pauseThe app moved to the background. Pause expensive work that is not already handled by the browser.
navigation:reselectThe user tapped the active bottom tab again while on its page. Commonly scrolls a feed to the top.
notification:openedThe user opened a push notification while the app was running in the background. The payload contains its URL, which the app opens itself. It does not fire when a notification launches a closed app.
sdk:errorA watcher, bridge command, or message validation failed. Intended for diagnostics, not sensitive logging.
The website must remain a website.
Not being inside the app is a normal state, not an exception. The SDK must avoid console noise and return predictable results when its native counterpart is absent.
| Feature | Normal browser behavior |
|---|---|
| ready | Resolves right away with available: false and platform: web. |
| page.setTitle | Updates document.title; native delivery reports false. |
| badges and item state | Do not fetch or render by default. Return delivered: false without throwing. |
| navigation.open | Uses location.assign, location.replace, or window.open according to the target. |
| navigation.back | Uses history.back when browser history is available. |
| share | Uses navigator.share where available. |
| notifications | Returns unsupported; it does not substitute the browser notification system. |
| haptics | Safe no-op with delivered: false. |
{ delivered: false, reason: 'not-in-app' }. Invalid arguments and native denials use typed SiteToAppError codes.Complete method reference.
SiteToApp.ready(options?)Wait for the handshake, then resolve to the current AppContext. It always resolves; a normal browser returns available: false.
SiteToApp.getContext()Return the last negotiated platform, versions, and capability list.
SiteToApp.page.setTitle(title)Update the app header and document.title together. Empty titles are rejected.
SiteToApp.page.resetTitle()Return the app header to the app name.
SiteToApp.page.setLoading(loading)Show or hide the app’s loading screen for a page transition or long-running action. It hides itself after eight seconds.
SiteToApp.page.setPullToRefresh(enabled)Enable or disable native pull-to-refresh for screens where the gesture is appropriate.
SiteToApp.navigation.setBadge(path, value)Set a badge on the menu items linking to a path. Zero, null, and false clear it.
SiteToApp.navigation.watchBadge(path, options)Fetch a badge value on load, on app resume, and optionally on an interval. Returns refresh and stop controls.
SiteToApp.navigation.setActive(path)Override the highlighted tab until the next page load. Pass null to return to automatic.
SiteToApp.navigation.setItem(path, state)Update the label, visibility, or enabled state of the menu items linking to a path.
SiteToApp.navigation.open(url, options?)Open a page on your website in the app, or an external HTTPS URL in the system browser.
SiteToApp.navigation.back()Ask the app to go back, falling through to browser history when no native route is available.
SiteToApp.navigation.handleOpen(handler)Let your router handle tab and notification changes instead of the page reloading. Returns a stop function.
SiteToApp.share(data)Open the native share sheet, with a navigator.share fallback on the web.
SiteToApp.notifications.requestPermission()Request notification permission after a user gesture and return the resulting status.
SiteToApp.notifications.openSettings()Open the operating system settings for this app when permission was previously denied.
SiteToApp.haptics.impact(style?)Request light, medium, or heavy impact feedback. This is a no-op when unsupported.
SiteToApp.on(event, handler)Subscribe to an app event and receive an unsubscribe function.
Typed errors: INVALID_ARGUMENT, UNSUPPORTED, NOT_ALLOWED, TIMEOUT, FETCH_FAILED, PROTOCOL_MISMATCH, and NATIVE_ERROR.
A small, acknowledged message envelope.
In the app, messages travel over the React Native WebView transport as serialized JSON through window.ReactNativeWebView.postMessage. The envelope is independent of that transport.
Website to app
{
"channel": "sitetoapp",
"protocolVersion": 1,
"id": "sta_01J8ZQ6JY1",
"type": "navigation.badge.set",
"payload": {
"path": "/cart",
"value": 3
},
"timestamp": 1789584000000
}App acknowledgement
{
"channel": "sitetoapp",
"protocolVersion": 1,
"type": "response",
"replyTo": "sta_01J8ZQ6JY1",
"ok": true,
"payload": null
}App to website
// Native shell → website. Internal SDK method; not public surface.
window.SiteToApp.__receive({
channel: 'sitetoapp',
protocolVersion: 1,
type: 'event',
event: 'app:resume',
payload: { occurredAt: Date.now() },
});- The SDK starts with
bridge.hello; the app responds with supported protocol versions and capabilities. - Every command gets a unique ID. Commands that need confirmation receive a response referencing that ID.
- Each page starts unconnected: the app sends it no events until it says hello.
- The default acknowledgement timeout is two seconds. Late responses are ignored after a timeout settles.
- Messages are JSON only, limited to 64 KB, and parsed exactly once on each side.
- Repeated title and badge changes are debounced; the latest valid value wins.
__receiveis implementation-only, non-enumerable where possible, and rejects messages outside the schema.
The bridge is narrow by design.
A WebView bridge creates native authority inside web content, so the app shell treats every page message as untrusted input—even when the page belongs to you. It enforces these rules:
The bridge only answers pages on your app’s website origin, with or without www. Messages from any other page are dropped, and replies check the origin before they run.
Message types are allowlisted and every payload is validated against the negotiated protocol schema. Unknown fields are ignored and unknown commands rejected.
Store credentials, push tokens, device identifiers, authentication cookies, and native filesystem paths are never exposed to page JavaScript.
Only relative URLs or validated HTTPS destinations are accepted. javascript:, data:, file:, intent:, and custom schemes are blocked unless a specific command allowlists them.
The SDK requires a recent user gesture for permission prompts and settings links. The app also limits how often a page can ask.
Commands are rate-limited, messages are capped at 64 KB, and strings and native calls have length limits and timeouts.
The bridge does not log payloads. Errors returned to the page contain a code and a short message, never internal details.
Watched badge requests follow browser Fetch rules. Same-origin credentials are the default; cross-origin URLs must pass CORS.
Test your integration.
The SDK only changes behavior inside your app. Check both paths so app users get the native experience and browser visitors keep the website working exactly as before.
- Open the pages that call the SDK in a normal mobile browser and confirm nothing breaks when
context.availableis false. - Test in the app on iOS and Android, including slow networks, offline state, and resuming after a long time in the background.
- Check titles, badges, and the highlighted tab after reloads and client-side route changes.
- For native actions, test the user-gesture, permission-denied, and unsupported paths.
- If your site uses a Content Security Policy, allow the SDK script origin.
Have a use case this contract misses?
Share the website flow, the app behavior you need, and what should happen in a normal browser. That is enough for us to evaluate it.