JavaScript bridge API
Sign-in and passkeys
A real Google account chooser, native rather than a web redirect, and passkeys through Android’s Credential Manager - navigator.credentials works as in Chrome, or use AppMint.passkey with your server’s JSON options.
AppMint.passkey helper#
window.AppMint.passkey — create(options), get(options), isAvailable(), origin(), assetLinks()
Example
Passkeys (sign in with fingerprint, face or screen lock - no password) through Android's passkey manager, using the JSON options your passkey server makes.
Returns: create(optionsJSON) → Promise of the RegistrationResponseJSON; get(optionsJSON) → Promise of the AuthenticationResponseJSON (send either to your server to verify). Both reject with a DOMException (err.name, and err.reason = a short code). isAvailable() → boolean, origin() → string, assetLinks() → array - all at once. Needs: Android 9+ with Google Play services, a passkey server, and the two set-up steps below. No build switch.
Sign in:
async function signInWithPasskey() {
if (!window.AppMint || !window.AppMint.passkey || !window.AppMint.passkey.isAvailable()) {
alert('Passkeys are not available on this phone.');
return;
}
var options = await (await fetch('https://your-domain.com/passkey/login-options')).json();
var answer;
try {
answer = await window.AppMint.passkey.get(options); // rpId must be your domain
} catch (err) {
if (err.name === 'NotAllowedError') return; // cancelled, or no passkey on this phone
alert('Passkey problem: ' + err.message);
return;
}
var res = await fetch('https://your-domain.com/passkey/login-verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer)
});
if (res.ok) showAccount();
}Create one (after the user signed in some other way):
async function addPasskey() {
var options = await (await fetch('https://your-domain.com/passkey/register-options')).json();
try {
var answer = await window.AppMint.passkey.create(options);
await fetch('https://your-domain.com/passkey/register-verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(answer)
});
} catch (err) {
if (err.name === 'InvalidStateError') alert('You already have a passkey here.');
else if (err.name !== 'NotAllowedError') alert('Passkey problem: ' + err.message);
}
}Set up once - run this in the app installed FROM Google Play (Play signs with its own key):
console.log(JSON.stringify(window.AppMint.passkey.assetLinks(), null, 2));
// save as https://your-domain.com/.well-known/assetlinks.json
console.log(window.AppMint.passkey.origin());
// android:apk-key-hash:... — add to your server's expected originsNotes: err.name / err.reason: NotAllowedError (cancelled, no_credential, interrupted), InvalidStateError (already exists), SecurityError with not_linked (assetlinks.json does not list the app) or rp_mismatch (the rpId is not this page's domain), NotSupportedError / unavailable (Android < 9 or no Play services), TypeError / invalid_options (a page bundled in the app has no domain, so set rp.id / rpId to your domain). window.AppMint.passkey exists only in the app; in a browser use navigator.credentials (the same code works in the app too).
WebToApkAuth.signInWithGoogle helper#
window.WebToApkAuth.signInWithGoogle(webClientId)
Example
Signs the user in with their Google account through Android's own account picker, and gives you a Google ID token. Google blocks its sign-in page inside app WebViews ("disallowed_useragent"); this native path is not blocked.
Returns: a Promise of {ok:true, idToken, email, displayName, profilePictureUri}. It REJECTS with an Error whose message is the code (cancelled, google_play_services_unavailable, missing_web_client_id, failed, …). Needs: your OAuth Web client ID (ends in .apps.googleusercontent.com), and the app's SHA-1 and SHA-256 fingerprints registered with your Google / Firebase project (from the _signkey_info.txt file of your build, and Play's App signing key if you publish on Play). No build switch.
With Firebase Authentication (compat SDK):
var WEB_CLIENT_ID = '1234567890-abc.apps.googleusercontent.com';
document.getElementById('google-button').addEventListener('click', async function () {
if (!window.WebToApkAuth) {
alert('Google sign-in works in the app.'); // or run your normal web sign-in here
return;
}
try {
var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
var cred = firebase.auth.GoogleAuthProvider.credential(r.idToken);
await firebase.auth().signInWithCredential(cred);
showWelcome(r.displayName || r.email);
} catch (err) {
if (err.message === 'cancelled') return; // user closed the picker
if (err.message === 'google_play_services_unavailable') {
alert('No Google account or Google Play services on this phone.');
return;
}
alert('Google sign-in failed: ' + err.message);
}
});With your own server - send the ID token and verify it there (never trust it in the page alone):
async function signInWithMyServer() {
var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
await fetch('https://your-domain.com/auth/google', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken: r.idToken })
});
}With Supabase Auth (hand the ID token to signInWithIdToken):
async function signInWithSupabase() {
if (!window.WebToApkAuth) {
return supabase.auth.signInWithOAuth({ provider: 'google' }); // normal web sign-in in a browser
}
try {
var r = await window.WebToApkAuth.signInWithGoogle(WEB_CLIENT_ID);
var res = await supabase.auth.signInWithIdToken({ provider: 'google', token: r.idToken });
if (res.error) throw res.error;
showWelcome(res.data.user.email);
} catch (err) {
if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
}
}Notes: Pass the Web client ID. With no argument the helper uses window.firebaseConfig._googleWebClientId if your page sets it, then the Web client in the google-services.json you uploaded for this app (only when that file is your own Firebase project with Google sign-in enabled - never an AppMint Push build); with none of these it rejects with missing_web_client_id. A page that is not the app's own (an external site open inside the app) is refused with foreign_origin. Use the WEB client ID, not an Android client ID. "No credentials available" usually means the SHA-1 is not registered. This helper exists only in the app - in a browser use your normal web sign-in. Call it from a button tap.
WebToApkAuth.signOutGoogle helper#
window.WebToApkAuth.signOutGoogle()
Example
Clears Android's saved Google sign-in choice, so the next signInWithGoogle shows the account picker again instead of silently reusing the last account.
Returns: a Promise of {ok:true}; rejects with an Error whose message says what went wrong. Needs: nothing.
Call it together with your own sign-out:
document.getElementById('sign-out').addEventListener('click', async function () {
await firebase.auth().signOut(); // your normal sign-out (Firebase here)
if (window.WebToApkAuth) {
try {
await window.WebToApkAuth.signOutGoogle();
} catch (err) {
console.log('Could not clear the Google choice: ' + err.message);
}
}
showSignInScreen();
});A "Switch account" button: clear the saved choice, then sign in again so the user picks another account:
async function switchGoogleAccount() {
if (!window.WebToApkAuth) return;
await firebase.auth().signOut();
try { await window.WebToApkAuth.signOutGoogle(); } catch (err) { /* nothing saved to clear */ }
try {
var r = await window.WebToApkAuth.signInWithGoogle('1234567890-abc.apps.googleusercontent.com');
await firebase.auth().signInWithCredential(firebase.auth.GoogleAuthProvider.credential(r.idToken));
} catch (err) {
if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
showSignInScreen();
}
}Sign out of your own server first, then clear the Google choice without letting a failure block the logout:
async function signOutEverywhere() {
await fetch('https://your-domain.com/auth/logout', { method: 'POST', credentials: 'include' });
localStorage.removeItem('profile');
if (window.WebToApkAuth) {
var r = await window.WebToApkAuth.signOutGoogle()
.catch(function (err) { return { ok: false, error: err.message }; });
if (!r.ok) console.log('Google choice not cleared: ' + r.error);
}
location.replace('/login');
}Notes: This does not end your app's own session - sign the user out of your backend yourself. It only resets which Google account the phone offers next time.
OTPCredential web standard#
navigator.credentials.get({ otp: { transport: ['sms'] } }) — WebOTP
Example
The standard WebOTP API: the login code from an SMS fills itself in. Android shows Google's "Allow this app to read this message?" sheet - no SMS permission.
Returns: a Promise of an OTPCredential {type:'otp', code}. Rejects AbortError when the user taps Deny or the signal aborts, InvalidStateError after five minutes without an SMS, NotSupportedError without Google Play services. Needs: nothing to switch on. The SMS must END with a line naming this page's host: @<host> #<code>.
const input = document.querySelector('input[autocomplete="one-time-code"]');
if ('OTPCredential' in window) {
const ac = new AbortController();
document.querySelector('form').addEventListener('submit', () => ac.abort());
navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })
.then((otp) => { input.value = otp.code; input.form.requestSubmit(); })
.catch((e) => console.log('Type the code instead:', e.name));
}The SMS your server sends (last line is the binding):
Your Acme code is 482913.
@acme.example #482913After "Resend code", start a fresh request (it replaces the one still waiting, which rejects with AbortError):
async function resendAndWait(phone) {
await fetch('/api/send-code', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone })
});
if (!('OTPCredential' in window)) return;
try {
const otp = await navigator.credentials.get({ otp: { transport: ['sms'] } });
await verifyCode(otp.code);
} catch (e) {
if (e.name === 'InvalidStateError') showMessage('No SMS yet. Tap Resend to try again.');
}
}Notes: the host must be the page's own host - for a website-based app that is your domain (the SMS Chrome already reads); for an offline/AI-built app it is appassets.androidplatform.net. A message for another host is ignored and the app keeps waiting. Top page only (codes bound to an embedded origin, @top #code @iframe, are not supported). Only one request waits at a time; a newer one aborts the older. Event behind it: appmint:otp.
__otpCancel bridge#
window.WebToApk.__otpCancel(requestId: String)
Abandons a pending WebOTP request (AbortSignal, or a newer request replaced it).
Example
Internal transport behind aborting a WebOTP request (AbortSignal). Pages use the standard API; never call this directly - the __ methods may change without notice.
Returns: nothing. Needs: nothing to switch on.
Use the public API:
const ac = new AbortController();
const cred = await navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal });
codeInput.value = cred.code;
ac.abort(); // the pending get() rejects with AbortErrorStop waiting as soon as the user starts typing the code themselves:
const input = document.querySelector('input[autocomplete="one-time-code"]');
const ac = new AbortController();
input.addEventListener('input', () => ac.abort(), { once: true });
navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })
.then((cred) => { input.value = cred.code; })
.catch((e) => { if (e.name !== 'AbortError') console.log('No code:', e.name); });Cancel the wait when the user goes back to change their phone number:
let otpAbort = null;
function waitForSms() {
otpAbort = new AbortController();
return navigator.credentials.get({ otp: { transport: ['sms'] }, signal: otpAbort.signal });
}
document.getElementById('change-number').addEventListener('click', () => {
if (otpAbort) { otpAbort.abort(); otpAbort = null; } // the pending get() rejects with AbortError
showPhoneStep();
});Notes: Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.
__otpStart bridge#
window.WebToApk.__otpStart(requestId: String)
WebOTP: waits (up to five minutes) for one SMS through Google's SMS User Consent sheet - no SMS permission. The answer is `appmint:otp {requestId, message|error}`.
Example
Internal transport behind navigator.credentials.get({ otp: { transport: ['sms'] } }). Pages use the standard API; never call this directly - the __ methods may change without notice.
Returns: nothing; the answer arrives as the appmint:otp event {requestId, message} or {requestId, error} (cancelled, timeout, unavailable). Needs: nothing to switch on.
Use the public API:
const ac = new AbortController();
const cred = await navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal });
codeInput.value = cred.code;Stop waiting after one minute with a timeout signal:
async function waitForCode(input) {
if (!('OTPCredential' in window)) return;
try {
const cred = await navigator.credentials.get({
otp: { transport: ['sms'] },
signal: AbortSignal.timeout(60000)
});
input.value = cred.code;
} catch (e) {
// TimeoutError after 60 s, AbortError when the user taps Deny
showMessage('Type the code from the SMS.');
}
}Tell the user what to do next from the error name:
async function readOtp() {
try {
const cred = await navigator.credentials.get({ otp: { transport: ['sms'] } });
return cred.code;
} catch (e) {
if (e.name === 'InvalidStateError') showResendButton(); // no SMS within five minutes
else if (e.name === 'NotSupportedError') showManualEntry(); // no Google Play services
return null; // AbortError: the user tapped Deny
}
}Notes: Implemented in the app shell (WebApiPolyfills.kt). The same page code works unchanged in Chrome.
passkeyAssetLinks bridge#
window.WebToApk.passkeyAssetLinks(): String
The assetlinks.json array (as text) that authorises this installed app for your domain.
Example
Builds the assetlinks.json content that tells Android your domain trusts this app (package name + the SHA-256 of the certificate the installed app is signed with). Pages normally use the public API AppMint.passkey.assetLinks(), which wraps this call and returns a parsed array.
Returns: a JSON array as TEXT, at once ("[]" if the certificate cannot be read). Needs: nothing.
Run once in the app installed FROM Google Play, then save the output as https://your-domain.com/.well-known/assetlinks.json:
if (window.AppMint && window.AppMint.passkey) {
console.log(JSON.stringify(window.AppMint.passkey.assetLinks(), null, 2));
}The raw call returns the text already formatted:
if (window.WebToApk && typeof window.WebToApk.passkeyAssetLinks === 'function') {
var text = window.WebToApk.passkeyAssetLinks();
console.log(text);
}Read the package name and SHA-256 fingerprint out of the statement, for a console that asks for them one by one:
if (window.AppMint && window.AppMint.passkey) {
var statements = window.AppMint.passkey.assetLinks();
if (statements.length === 0) {
console.log('The signing certificate could not be read.');
} else {
var target = statements[0].target;
console.log('Package: ' + target.package_name);
console.log('SHA-256: ' + target.sha256_cert_fingerprints[0]); // AB:CD:... upper-case, colon-separated
}
}Notes: The domain must be your server's rpID exactly (www. is a different domain), served over https with no redirect. If the file is wrong, passkey calls fail with SecurityError (reason not_linked).
passkeyIsAvailable bridge#
window.WebToApk.passkeyIsAvailable(): Boolean
True on Android 9+ with Google Play services - the phones that can hold a passkey.
Example
Tells you whether this phone can use passkeys. Pages normally use the public API AppMint.passkey.isAvailable() (or PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()), which wraps this call.
Returns: a boolean, at once - true on Android 9+ with Google Play services and a WebView new enough for the passkey channel. Needs: nothing.
The public API:
var canPasskey = !!(window.AppMint && window.AppMint.passkey && window.AppMint.passkey.isAvailable());
document.getElementById('passkey-button').hidden = !canPasskey;The raw call, if you need it:
var canPasskey = !!window.WebToApk &&
typeof window.WebToApk.passkeyIsAvailable === 'function' &&
window.WebToApk.passkeyIsAvailable() === true;Offer "Add a passkey" once after a password sign-in, only on phones that can hold one (the standard check, same code in Chrome):
async function offerPasskeyAfterLogin() {
var ok = !!window.PublicKeyCredential &&
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!ok) return; // Android below 9 or no Google Play services
if (localStorage.getItem('passkey-offered')) return;
localStorage.setItem('passkey-offered', '1');
document.getElementById('add-passkey-banner').hidden = false;
}Notes: When this is false, AppMint.passkey.create/get reject with NotSupportedError. Hide the passkey button and offer your other sign-in.
passkeyOrigin bridge#
window.WebToApk.passkeyOrigin(): String
The origin a passkey server sees from this app: `android:apk-key-hash:<base64url SHA-256>`.
Example
Gives the origin your passkey server sees from this app: android:apk-key-hash:<base64url SHA-256 of the signing certificate>. Pages normally use the public API AppMint.passkey.origin(), which wraps this call.
Returns: a string, at once ("" if the certificate cannot be read). Needs: nothing.
Run this once in the app installed FROM Google Play, and add the value to your server's allowed origins:
if (window.AppMint && window.AppMint.passkey) {
console.log(window.AppMint.passkey.origin()); // android:apk-key-hash:Lir5oIjf552K...
}The raw call gives the same string:
if (window.WebToApk && typeof window.WebToApk.passkeyOrigin === 'function') {
console.log(window.WebToApk.passkeyOrigin());
}Show the origin on a hidden settings screen, so you can read it off the Play install without a debugger:
var out = document.getElementById('passkey-origin');
var origin = (window.AppMint && window.AppMint.passkey) ? window.AppMint.passkey.origin() : '';
if (!origin) {
out.textContent = 'Open this screen in the installed app.';
} else {
out.textContent = origin; // android:apk-key-hash:...
out.style.userSelect = 'all'; // one long-press selects the whole value
}Notes: Google Play re-signs your app, so a test APK gives a different value than the Play install. SimpleWebAuthn example: expectedOrigin: ['https://your-domain.com', 'android:apk-key-hash:...'].
signInWithGoogle bridge#
window.WebToApk.signInWithGoogle(serverClientId: String, callbackId: String)
===== Native Google Sign-In via Credential Manager ===== Bypasses Google's Android WebView OAuth block (disallowed_useragent error) by running the sign-in flow in native Android and bridging just the resulting Google ID token back to JS, where the Firebase Web SDK consumes it via GoogleAuthProvider.credential(idToken) + signInWithCredential(...). JS contract: window.WebToApk.signInWithGoogle(serverClientId, callbackId) serverClientId "" = the Web client (type 3) in the creator's own google-services.json for this package; with none, error "missing_web_client_id". A page that is not the app's own gets error "foreign_origin". -> on completion, window.__webToApkAuth[callbackId](resultJsonString) is called resultJsonString = '{"ok":true,"idToken":"...","email":"...","displayName":"..."}' or '{"ok":false,"error":"<message>"}'
Example
Starts native Google sign-in (Android's account picker) and answers with a Google ID token. Pages normally use the Promise helper WebToApkAuth.signInWithGoogle(webClientId), which wraps this call.
Returns: nothing. The answer arrives later: the shell calls window.__webToApkAuth[callbackId](json) with '{"ok":true,"idToken":"...","email":"...","displayName":"...","profilePictureUri":"..."}' or '{"ok":false,"error":"<code>","detail":"..."}', then removes that entry. Needs: your OAuth Web client ID, and the app's SHA-1 / SHA-256 registered with your Google or Firebase project. No build switch.
The helper (recommended):
async function googleSignIn() {
if (!window.WebToApkAuth) return null;
try {
var r = await window.WebToApkAuth.signInWithGoogle('1234567890-abc.apps.googleusercontent.com');
return r.idToken;
} catch (err) {
if (err.message !== 'cancelled') alert('Google sign-in failed: ' + err.message);
return null;
}
}The raw call (argument order: web client ID, then your callback id):
function googleSignInRaw(done) {
if (!window.WebToApk || typeof window.WebToApk.signInWithGoogle !== 'function') return;
var id = 'g_' + Date.now();
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth[id] = function (json) {
var r = JSON.parse(json);
if (r.ok) done(r.idToken, r.email);
else if (r.error !== 'cancelled') alert('Google sign-in failed: ' + r.error);
};
window.WebToApk.signInWithGoogle('1234567890-abc.apps.googleusercontent.com', id);
}The raw call with an empty client ID, which uses the Web client from the google-services.json you uploaded:
function googleSignInFromUploadedFile() {
return new Promise(function (resolve, reject) {
if (!window.WebToApk || typeof window.WebToApk.signInWithGoogle !== 'function') {
return reject(new Error('not_in_app'));
}
var id = 'g_' + Date.now();
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth[id] = function (json) {
var r = JSON.parse(json);
if (r.ok) resolve(r); else reject(new Error(r.error));
};
window.WebToApk.signInWithGoogle('', id);
});
}
googleSignInFromUploadedFile()
.then(function (r) { showProfile(r.displayName, r.profilePictureUri); })
.catch(function (e) {
if (e.message === 'missing_web_client_id') console.log('Pass your Web client ID: the uploaded file has none.');
});Notes: Error codes: cancelled (user closed the picker), google_play_services_unavailable (no Google account or provider - also what a missing SHA-1 registration looks like), missing_web_client_id (none passed, and no Web client in the google-services.json you uploaded), foreign_origin (the page on screen is not the app's own), failed, unexpected_credential_type, parse_failed: .... An empty first argument uses the Web client from your own uploaded google-services.json when it has one. Verify the ID token on your server (or hand it to Firebase with GoogleAuthProvider.credential(idToken)).
signOutGoogle bridge#
window.WebToApk.signOutGoogle(callbackId: String)
Clears the saved Credential Manager state so the next signInWithGoogle call shows the account picker again (rather than silently re-selecting the last account). Generated apps should call this on user-initiated sign-out, in addition to firebase.auth().signOut().
Example
Clears Android's saved Google sign-in state, so the next sign-in shows the account picker again. Pages normally use the Promise helper WebToApkAuth.signOutGoogle(), which wraps this call.
Returns: nothing. The answer arrives later: the shell calls window.__webToApkAuth[callbackId](json) with '{"ok":true}' or '{"ok":false,"error":"..."}'. Needs: nothing.
The helper (recommended):
async function signOut() {
await myBackendSignOut(); // end your own session first
if (window.WebToApkAuth) {
try { await window.WebToApkAuth.signOutGoogle(); } catch (err) { /* nothing to undo */ }
}
}The raw call:
if (window.WebToApk && typeof window.WebToApk.signOutGoogle === 'function') {
var id = 'gout_' + Date.now();
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth[id] = function (json) {
var r = JSON.parse(json);
if (!r.ok) console.log('Google sign-out failed: ' + r.error);
};
window.WebToApk.signOutGoogle(id);
}The raw call wrapped in a Promise, used when the user deletes their account:
function clearGoogleChoice() {
return new Promise(function (resolve) {
if (!window.WebToApk || typeof window.WebToApk.signOutGoogle !== 'function') return resolve(false);
var id = 'gout_' + Date.now();
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth[id] = function (json) { resolve(JSON.parse(json).ok === true); };
window.WebToApk.signOutGoogle(id);
});
}
async function deleteAccount() {
await fetch('https://your-domain.com/account', { method: 'DELETE', credentials: 'include' });
await clearGoogleChoice(); // the next sign-in starts from the account picker
showSignInScreen();
}Notes: It does not sign the user out of Firebase or your server - do that yourself.