Embedding the widget in a mobile app
There is no native SDK. You embed the same web SDK your website would use, by serving a small host page on your own domain and loading it in a full-screen WebView. Budget a day for a working screen, and read Camera and microphone and Scan payments before you start: those two are where integrations lose time.
You host one HTML file
It must be served over https from a real host, and that origin must be listed in your dashboard. A local file or an HTML string handed to the WebView will not work.
Inline, not float
Use mode: 'inline' at 100% by 100%. The widget then owns the screen and draws its own header, with no floating launcher and no page chrome.
Events come back as JSON
Your host page forwards the widget's callback to the native side. Treat minimized as "the user closed this screen".
How it works
The only piece you write is the host page, and it is about forty lines. The bundle needs a page with a real origin to work from, which is why the WebView loads a URL rather than a string of HTML: the session request carries that origin, and your account only accepts origins you have listed.
This is also why the same integration works for every widget type. Swap createScanWidget for createConsultationWidget, createChatWidget, createVoiceAgentWidget or createUnifiedWidget and nothing else changes.
Before you start
- Copy your public key. Widget Manager in your Oyster dashboard. It is safe to ship inside the app: it identifies your account, it does not authorise anything on its own.
- Add your host page's origin to Allowed domains / origins, one per line. This is an exact match on scheme, host and port, with no wildcards. https://app.yourbrand.com does not cover https://www.yourbrand.com. Add every origin you will serve the page from, including staging.
- Pick a bundle. The URL you load decides which environment you talk to, so use sandbox while you build and swap one string to go live. Public keys are not shared between the two.
// Sandbox, while you build
https://sandbox.widget-lib.oysterskin.com/v1/oysterskin-vendor-widget-web.umd.js
// Production
https://widget-lib.oysterskin.com/v1/oysterskin-vendor-widget-web.umd.js
/v1/ is the channel to use: you get fixes without chasing releases, and no breaking change lands inside a major. Treat /latest/ as testing-only, since it can cross a major. If your release process needs a frozen artifact rather than a moving channel, talk to us before you build around one.
The host page
Serve this from your own domain
A static file is enough. It needs no backend, no build step and no framework. Change the public key and the bundle URL, deploy it at a stable path, and you are done with the web side.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>Skin scan</title>
<style>
html, body { height: 100%; margin: 0; background: #fcfbfe; -webkit-text-size-adjust: 100%; }
#widget-root { height: 100%; position: relative; }
#placeholder {
position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; padding: 24px; color: #6b7280; text-align: center;
font: 15px/1.5 system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
}
</style>
</head>
<body>
<div id="widget-root"><div id="placeholder">Loading…</div></div>
<script>
(function () {
var PUBLIC_KEY = 'your-public-key';
var LOADER = 'https://sandbox.widget-lib.oysterskin.com/v1/oysterskin-vendor-widget-web.umd.js';
// React Native. Swap this for webkit.messageHandlers (iOS) or your
// addJavascriptInterface object (Android) if you embed natively.
function send(message) {
if (!window.ReactNativeWebView) return;
window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
function boot() {
var options = {
publicKey: PUBLIC_KEY,
mode: 'inline',
container: '#widget-root',
autoOpen: true,
inlineWidth: '100%',
inlineHeight: '100%',
callback: send,
// Reports this traffic as your app rather than your website. Same
// billing, same emails, same dashboard numbers; it only changes the
// label in reporting.
app: 'vendor-app',
// A WebView has no window the widget can open, so it hands hosted
// checkouts to you instead. Open it natively and answer 'opened'.
// Nothing about payment or confirmation changes.
onOpenCheckout: function (request) {
send({ event: 'openCheckout', data: request });
return { status: 'opened' };
},
};
// Set by the app before this page's scripts run. Read it now, not at
// parse time: on Android the injection lands slightly later.
var user = window.__oysterAppUser;
if (user && typeof user === 'object') {
options.user = user;
try { delete window.__oysterAppUser; } catch (e) {}
}
window.OysterskinWidget.createScanWidget(options);
var placeholder = document.getElementById('placeholder');
if (placeholder) placeholder.remove();
}
// Appended rather than inline, so onerror can tell your app that the
// bundle never arrived instead of leaving a blank screen.
var script = document.createElement('script');
script.src = LOADER;
script.onload = boot;
script.onerror = function () {
send({ event: 'hostError', data: { reason: 'loader-unreachable' } });
};
document.head.appendChild(script);
})();
</script>
</body>
</html>
A ready-to-deploy version of this file, with the iOS and Android bridges filled in as well: Download the host page
Why each part is there
| Line | Why |
|---|---|
mode: 'inline' |
Mounts the widget straight into your container. Float mode would draw a launcher button inside the WebView, which makes no sense when the screen is already the widget. |
container |
Required in inline mode, and the element has to exist when you create the widget. Creating the widget before the DOM is parsed throws. |
inlineWidth / inlineHeight |
'100%' of a container that is itself height: 100%. A
container with only a min-height collapses to nothing.
|
callback |
Forward everything and let the native side pick. Filtering here means shipping a web change to handle a new event. |
viewport meta |
user-scalable=no on this page also pins the widget inside it, so the
screen cannot be pinch-zoomed like a web page.
|
#placeholder |
Painted before any script runs, so there is no white flash while the bundle downloads. Removed once the widget takes over. |
script.onerror |
A bundle that never arrives leaves a page that loaded fine and shows nothing. Your WebView sees success. This is the only way the native side learns about it. |
Everything else is optional. Colour, logo, copy, budget tiers and which tiles the unified widget shows can all be set once in Widget Manager and are fetched at runtime, so you can rebrand without an app release. Anything you pass from code wins over the saved value, so pass only what is genuinely app-specific. See Where config comes from.
The WebView
The settings below are not defaults. Three of them control whether the camera works at all, and one controls whether a link can strand the user on a page with no way back. The React Native version is complete; the native snippets that follow show the same decisions in Swift and Kotlin.
React Native
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Linking, StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';
import type { WebViewMessageEvent, WebViewNavigation } from 'react-native-webview';
// Not re-exported from the package root, unlike the two above.
import type { WebViewOpenWindowEvent } from 'react-native-webview/lib/WebViewTypes';
const HOST_URL = 'https://app.yourbrand.com/oyster/scan.html';
const HOST_ORIGIN = 'https://app.yourbrand.com';
/** The widget's own header close button emits `minimized`. */
const DISMISS_EVENTS = new Set(['minimized', 'closed']);
/** An unreachable host can fire neither onLoadEnd nor onError, spinning forever. */
const LOAD_TIMEOUT_MS = 30_000;
/** Only http(s) can be handed to the OS. `about:blank` and `blob:` are page-internal. */
const EXTERNAL_URL = /^https?:\/\//i;
export type OysterUser = {
id: string;
name?: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
// JSON.stringify emits U+2028/U+2029 raw, which older JS engines reject inside a
// string literal. Escaping them keeps the generated script parseable everywhere.
const toLiteral = (value: unknown) =>
JSON.stringify(value).replace(/[\u2028\u2029]/g, (c) => '\\u' + c.charCodeAt(0).toString(16));
/**
* The origin check lives INSIDE the script on purpose. This prop re-runs on every
* main-frame navigation, so the gate has to be evaluated against whatever document
* is actually loading, not the URL you started at.
*/
const buildUserScript = (user: OysterUser | null): string | undefined => {
if (!user) return undefined;
return `(function () {
try {
if (window.location.origin !== ${toLiteral(HOST_ORIGIN)}) return;
window.__oysterAppUser = ${toLiteral(user)};
} catch (e) {}
})();
true;`;
};
export function OysterScanScreen({
user,
onDismiss,
}: {
user: OysterUser | null;
onDismiss: () => void;
}) {
const webViewRef = useRef<WebView>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const [offHost, setOffHost] = useState(false);
const canGoBack = useRef(false);
const watchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const userScript = useMemo(() => buildUserScript(user), [user]);
const clearWatchdog = useCallback(() => {
if (watchdog.current) clearTimeout(watchdog.current);
watchdog.current = null;
}, []);
const armWatchdog = useCallback(() => {
clearWatchdog();
watchdog.current = setTimeout(() => setFailed(true), LOAD_TIMEOUT_MS);
}, [clearWatchdog]);
useEffect(() => clearWatchdog, [clearWatchdog]);
// Untrusted input: the page can navigate. Parse defensively, act on known events.
const handleMessage = (event: WebViewMessageEvent) => {
let message: unknown;
try {
message = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
if (typeof message !== 'object' || message === null) return;
const { event: name, data } = message as { event?: unknown; data?: unknown };
if (typeof name !== 'string') return;
if (DISMISS_EVENTS.has(name)) {
onDismiss();
return;
}
if (name === 'hostError') {
setFailed(true);
return;
}
if (name === 'openCheckout') {
// System browser, never this WebView: following it in place would replace
// the widget and lose the scan behind it.
const url = (data as { checkoutUrl?: unknown })?.checkoutUrl;
if (typeof url === 'string' && EXTERNAL_URL.test(url)) {
Linking.openURL(url).catch(() => {});
}
return;
}
if (name === 'checkout') {
// data.checkout_items -> your native cart
}
};
// window.open has nowhere to go inside a WebView. Hand it to the OS rather than
// letting it replace the page and take the widget's close button with it.
const handleOpenWindow = (event: WebViewOpenWindowEvent) => {
const { targetUrl } = event.nativeEvent;
if (!EXTERNAL_URL.test(targetUrl)) return;
Linking.openURL(targetUrl).catch(() => {});
};
// Once the widget is up it draws its own header and close control, so your own
// escape hatch is only needed while it does not own the screen.
const showEscapeHatch = !loaded || offHost || failed;
return (
<View style={styles.container}>
{showEscapeHatch && <YourCloseButton onPress={onDismiss} />}
<WebView
ref={webViewRef}
source={{ uri: HOST_URL }}
style={styles.webview}
onMessage={handleMessage}
onNavigationStateChange={(nav: WebViewNavigation) => {
canGoBack.current = nav.canGoBack;
setOffHost(!nav.url.startsWith(`${HOST_ORIGIN}/`));
}}
onLoadStart={() => {
setFailed(false);
setLoaded(false);
armWatchdog();
}}
onLoadEnd={() => {
setLoaded(true);
clearWatchdog();
}}
onError={() => setFailed(true)}
onHttpError={() => setFailed(true)}
startInLoadingState
renderLoading={() => <ActivityIndicator style={styles.loading} />}
renderError={() => <YourRetryView onRetry={() => webViewRef.current?.reload()} />}
javaScriptEnabled
// The widget keeps step state in sessionStorage.
domStorageEnabled
// --- Camera: all three are required, and the third is the subtle one ---
allowsInlineMediaPlayback
// The widget's iframe is a different host to your page, so the
// "grant if same host" variants will not cover it.
mediaCapturePermissionGrantType="grant"
// Without this the camera preview never starts playing, and face detection
// waits forever instead of failing. Defaults to true.
mediaPlaybackRequiresUserAction={false}
// Leave multiple-window support at its default: Android will not report a
// window.open to you at all otherwise.
onOpenWindow={handleOpenWindow}
// Make it behave like a screen, not a document you can pinch and drag.
bounces={false}
overScrollMode="never"
setBuiltInZoomControls={false}
// Gated on the live document's origin, because this re-runs on every
// navigation. Left undefined when logged out, so nothing is injected.
injectedJavaScriptBeforeContentLoaded={userScript}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fcfbfe' },
webview: { flex: 1 },
loading: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
});
On Android, add the hardware back button too, or the first press throws away a half-filled form instead of stepping back inside the flow.
// Android: step back through the page before closing the screen, or a half-filled
// form is thrown away on the first press of the hardware back button.
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (!canGoBack.current) return false; // nothing left: let the screen close
webViewRef.current?.goBack();
return true;
});
return () => sub.remove();
}, []);
iOS, WKWebView
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = [] // camera preview autoplays
config.userContentController.add(self, name: "oyster") // receives widget events
// Injected before the page's own scripts run. Gate on the origin: this runs for
// every document the web view loads, not just the one you started at.
let script = WKUserScript(
source: """
(function () {
try {
if (window.location.origin !== 'https://app.yourbrand.com') return;
window.__oysterAppUser = \(userJSON);
} catch (e) {}
})();
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
config.userContentController.addUserScript(script)
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: URL(string: "https://app.yourbrand.com/oyster/scan.html")!))
// Grant camera without a second prompt. The widget runs in a cross-origin frame,
// so a decision scoped to the page's own host will not cover it.
func webView(_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
decisionHandler(.grant)
}
Android, WebView
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false // camera preview autoplays
webView.settings.builtInZoomControls = false
// Receives widget events. Keep the interface to this one method.
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String) { /* hand to your view model */ }
}, "OysterAndroid")
webView.webChromeClient = object : WebChromeClient() {
// Camera. The app must also hold the CAMERA runtime permission before this,
// or granting here still yields no video.
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
// window.open has nowhere to go in a WebView. Hand http(s) targets to the OS
// so the widget and its close button stay on screen behind you.
override fun onCreateWindow(
view: WebView, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message
): Boolean = false
}
webView.loadUrl("https://app.yourbrand.com/oyster/scan.html")
Events
Every message is a JSON string of { event, data }. Parse defensively and ignore what you do not recognise: the page can navigate, and new events are added over time.
| Event | What it means, and what to do |
|---|---|
minimized |
The user pressed the widget's own close button. Pop your screen. This is the one you must handle, and the name is a leftover from float mode where the same control returns the widget to its launcher. |
closed |
Handle it the same way. The widget does not emit it today, so handling it costs nothing and covers you if that changes. |
scanCompleted |
{ batch_id }. A scan finished and produced results. Good place to refresh
whatever your app shows about the customer.
|
checkout |
The user checked out from inside the widget. Carries totals, currency,
checkout_items, and user when a profile is known. Hand it to
your native cart and close the screen.
|
cartItemAdded / cartItemRemoved |
Per-item changes, if you want to mirror the cart live rather than wait for checkout.
checkout_items can be empty when the widget has no inventory-backed id
for an item yet.
|
openCheckout |
Also yours, forwarded by the host page's onOpenCheckout. Carries
checkoutUrl. Open it in the system browser. See
Scan payments.
|
hostError |
Yours, not the widget's, emitted by the host page above. It is the only signal that distinguishes "the bundle never loaded" from "still loading". Show your retry state. |
Full payload shapes are in Events and callback.
The signed-in user
Passing a user prefills the contact step and ties the scan to the profile you already have. Omit it and the widget asks for an email when it needs one, which still works but costs the customer a step. The accepted shape is { id, name?, firstName?, lastName?, email?, phone? }.
Two ways to get it to the host page, and the difference is where the data ends up:
| Approach | Trade-off |
|---|---|
| Query string |
Simplest: read location.search in the host page. But the values land in
your web server's access logs and in any proxy in front of it, which is a poor home
for an email address.
|
| Script injection | Nothing appears in a URL or a log. Costs a few lines, shown in the WebView snippets above. This is the one to use. |
Three things that catch people out
- Gate the injection on the live origin, inside the script. The injected script re-runs on every main-frame navigation, so a check you do once in native code before the first load does not protect the payment page the user reaches later. Compare strings rather than embedding a regex: written as source text, '\.' parses back to . and quietly matches anything.
- Read the value late. On Android the injection lands slightly after document start, so the host page reads it when the DOM is ready rather than at parse time.
- Send phone numbers in E.164. The widget infers the country from the dialling code, so +2348012345678 works and 08012345678 selects the wrong country and produces a value the form rejects.
This object is the whole ceiling on what personal data leaves your app. Nothing else in your user record should go near it.
Camera and microphone
A scan needs the camera, and a WebView gives you three separate ways to end up with a frozen preview and no error message. All of these are required together.
| Requirement | Why, and what happens without it |
|---|---|
| https host page | Camera access needs a secure context. On a page without one it is not just denied, the API is absent. |
| Native permission declared |
iOS NSCameraUsageDescription, Android
android.permission.CAMERA. Add
NSMicrophoneUsageDescription and RECORD_AUDIO only if you
enable the voice agent.
|
| Grant unconditionally |
The widget runs in a frame whose host differs from your page's, so a policy of "grant
if the same host asked" will not cover it. In React Native that is
mediaCapturePermissionGrantType="grant".
|
| Inline playback | Without it the preview tries to go fullscreen in the platform player instead of rendering in the page. |
| No gesture required for playback |
The subtle one. It defaults to requiring a gesture, and the camera
preview then stays paused. Face detection waits on a video that never becomes ready,
so the screen hangs on "analysing" forever rather than failing. In React Native,
mediaPlaybackRequiresUserAction={false}.
|
Scan payments
Whether a scan has to be paid for, and by whom, is a setting on your account rather than something you choose in code. Every arrangement works inside an app, and the way to make sure of that is one callback: a WebView cannot open a browser window on the page's behalf, so register onOpenCheckout and the widget hands you anything it needs opened.
| Arrangement | In an app |
|---|---|
| Scans billed to you | Nothing to do. The customer is never asked to pay, so no payment step appears. This is the simplest thing to ship first. |
| Customer pays, Oyster collects |
Register onOpenCheckout. Some checkouts draw
themselves inside the widget and never call it; others have to be opened. You do not
have to know which you will get, and it can change without you.
|
| Customer pays, you collect |
Also works. You get a callback, you collect natively however you
already take money, and your server confirms. Ask support to enable it. Register
onOpenCheckout here too if your own checkout is a hosted page.
|
Opening the checkout
Register onOpenCheckout and the widget stops trying to open windows altogether. It hands you checkoutUrl, you open it the way your platform does, and you answer whether it opened. That is the entire contract: the payment is still collected and confirmed exactly as it is on the web, and the widget waits for that confirmation on its own.
onOpenCheckout: function (request) {
// request: { checkoutUrl, reference, batchId, vendorSlug }
send({ event: 'openCheckout', data: request });
return { status: 'opened' };
},
Natively, open it in the system browser rather than in the WebView. Following it in place replaces the widget and the customer loses their scan.
// React Native, in your onMessage handler
if (name === 'openCheckout' && typeof data?.checkoutUrl === 'string') {
Linking.openURL(data.checkoutUrl).catch(() => {});
}
Answer { status: 'failed' } or { status: 'cancelled' } if you cannot open it, so the customer is told rather than left watching a spinner. Register it even if payments work without it today: it is inert when nothing needs opening, and it is what stops a pricing or currency change quietly breaking payments in a shipped app. Leave it off on a website, where the widget's own window is the right mechanism.
Collecting it yourself
Register onCollectPayment on the host page. Return processing, take the payment on your own native checkout, and let your server settle it.
var options = {
publicKey: PUBLIC_KEY,
mode: 'inline',
container: '#widget-root',
callback: send,
onCollectPayment: function (request) {
// request: { reference, amount, currency, email, batchId, vendorSlug, label }
// Hand it to the app and collect natively. Keep `reference`: your server
// confirms against it.
send({ event: 'collectPayment', data: request });
// Nothing to show in the WebView, so tell the widget to wait. It listens for
// your server's confirmation and admits the scan when it lands.
return { status: 'processing' };
},
};
Two consequences worth knowing before you ask for this: every scan is invoiced to you, including the ones the customer paid for, and your Oyster scan markup no longer applies because you set the price and keep it.
One small thing regardless of arrangement: the payment step may call window.open before it knows where it is sending the customer, which surfaces natively as a request to open about:blank. Ignore anything that is not http(s), as the snippets above do, and it closes itself.
Tablets and large screens
Below 768 CSS pixels the widget fills the viewport, which is what you want on a phone. At or above it the capture steps switch to a centred card of fixed size and report that size back, and the bundle sizes the embed to match. So on a tablet an embed that filled the screen can become a card partway through the flow.
That is usually the better layout on a tablet, and it needs nothing from you beyond giving the container a background and centring it, so the card does not sit in a corner against bare white. If you would rather keep the phone layout, constrain the WebView to a phone-width column. Either way, run the capture step once on a tablet before you ship.
Testing
- Use a real device for anything involving the camera. Simulators have no camera, so every camera problem in this guide is invisible until you hold a phone.
- Open the host page in the device's own browser first. If it works there and not in your WebView, the problem is a WebView setting. If it fails in both, the problem is the key, the origin, or the bundle URL.
- Attach a desktop debugger. Safari Web Inspector for iOS, chrome://inspect for Android. The widget logs what it is doing, and the network panel shows a rejected session immediately.
- Test airplane mode and a dead host. Point the URL at a hostname that does not resolve and confirm you reach your retry state rather than a permanent spinner.
- Test the back button on Android at three points: on the first screen, mid flow, and after results.
- Test a cold start with no signed-in user. The widget should ask for an email rather than fail.
Troubleshooting
| What you see | What it is |
|---|---|
Blank screen, 400 Origin is required. |
The page has no origin. You are loading a local file, or handing the WebView an HTML string instead of a URL. Serve the file over https and load its URL. |
403 Widget domains are not configured for this vendor. |
No allowed origins are saved on your account. Add your host page's origin in Widget Manager. |
403 This domain is not allowed for this widget. |
Your origin is not an exact match for a saved one. Check scheme, subdomain and port,
all three. Also triggered by any example.* host, and by
localhost against production.
|
401 Invalid public key. |
Usually a sandbox key against the production bundle, or the reverse. Keys are not shared between environments. |
429 after a few reloads |
Session creation is rate limited per account, and hot reload can burn through it fast. Wait a minute. |
OysterskinWidget is undefined |
The bundle did not load, or you created the widget before onload. Check the URL and that nothing on your host is blocking it. |
| Camera opens, preview is frozen, never progresses | Playback still requires a gesture. See Camera and microphone. This one produces no error anywhere. |
| Camera never opens | Either the native permission is missing from the manifest, or the grant policy is scoped to the page's own host and so does not cover the widget's frame. |
| Widget renders 1px tall, or not at all |
The container has no height. It needs height: 100% up the chain, not just
a min-height.
|
| Throws on create: container did not match | The widget was created before the DOM was parsed. Create it on DOMContentLoaded. |
| A tap replaces the widget and there is no way back | A window.open followed in place. Hand http(s) targets to the OS. See Back, close and dead ends. |
| Payment asks the customer to allow pop-ups | A hosted checkout is being used, which a WebView cannot open. See Scan payments. |
| White flash before the widget appears | No placeholder painted before the bundle downloads. Set the same background on the page and on your native screen. |
Launch checklist
- Bundle switched from sandbox to production, and the public key with it.
- Production origin saved in Widget Manager, not just staging.
- Camera verified on a real iPhone and a real Android device.
- Permission strings in place, and honest about what the camera is for.
- Escape hatch reachable while loading, off-origin, and on error.
- Watchdog in place, tested against a hostname that does not resolve.
- Android back button steps through the flow before closing the screen.
- Events handled, unknown ones ignored rather than thrown on.
- Payments decided, and if you collect them, confirmed from your server.
- No API key anywhere in the app or the host page. The public key is the only key that ships.
Something not covered here, or behaving differently from what this says? Mail [email protected] with the widget's console output and we will take a look.