Create Free APK

Start here

Writing HTML for your app

What the app runtime exposes to your page - contacts, SMS, notifications, device info, biometrics - and the rules for calling it correctly

1Rule 1 - always check first#

window.WebToApk only exists inside your built app. In a browser it is missing.

So always check before you call. If you do not, your page breaks everywhere except inside the app.

Some things need no check at all: navigator.vibrate(), speechSynthesis and Notification already work - the app connects them for you. Just use the normal web code.

// GOOD β€” safe everywhere
if (window.WebToApk) {
  WebToApk.playClick();
}

// BAD β€” this breaks your page in a browser
WebToApk.playClick();

2Rule 2 - some answers come back later#

Reading contacts, SMS or the call log takes time. So these do not answer straight away. If they did, your app would freeze.

You give the call a name (an id). The answer arrives later as an event. You listen for that event.

Event names: appmint:contacts, appmint:calllog, appmint:sms, appmint:sms-received, appmint:notification-action.

// 1. Listen for the answer
window.addEventListener('appmint:contacts', function (e) {
  if (e.detail.error) {
    alert('Problem: ' + e.detail.error);
    return;
  }
  console.log(e.detail.contacts);
});

// 2. Ask the question ('my1' is any name you choose)
WebToApk.listContacts('my1', 50, 0);

3Rule 3 - two switches must be ON#

A phone feature needs TWO things:

  1. You turned it on in Step 4 (Access) when you built the app. If not, you get error 'not_enabled' and the user sees nothing.
  1. The user said Yes on the phone. If they said No, you get error 'permission_denied'.

Always handle both errors so the user knows what happened.

Easier way: pickContact() and composeSms() need NO permission at all. Use them when they fit - they do the same job with less trouble.

window.addEventListener('appmint:contacts', function (e) {
  if (e.detail.error === 'not_enabled') {
    alert('Turn on Contacts when you build the app');
  } else if (e.detail.error === 'permission_denied') {
    alert('Please allow contacts access');
  } else {
    showContacts(e.detail.contacts);
  }
});

4Notifications with pictures and buttons#

notify() takes a list of options. You can add a big picture, an icon, buttons, and a progress bar.

channel can be: urgent, default, quiet, or ongoing.

Use ongoing:true for a notification the user cannot swipe away. Note: they can still remove it from Android settings. No app can make one that is impossible to remove.

Use tag to give it a name. Sending again with the same tag replaces the old one instead of adding a new one.

WebToApk.notify(JSON.stringify({
  title: 'Order shipped',
  body: 'Arriving Tuesday',
  channel: 'urgent',
  image: 'https://mysite.com/box.jpg',
  actions: [{ id: 'track', label: 'Track' }],
  tag: 'order-482'
}));

// Know which button the user pressed
window.addEventListener('appmint:notification-action', function (e) {
  if (e.detail.actionId === 'track') showTracking();
});

5Phone information and fingerprint#

getDeviceInfo() needs no permission. It gives you the Android version, the version ID (buildId), the phone model, screen size, battery level, and more.

About device ID: there is NO IMEI or serial number. Android blocked this for every app from Android 10. Use getInstallId() instead - a fixed ID for this install that stays the same after updates.

var info = JSON.parse(WebToApk.getDeviceInfo());
info.android.release;      // "14"
info.android.buildId;      // "TQ3A.230805.001"  <- version ID
info.hardware.model;       // "Pixel 7"
info.runtime.batteryLevel; // 82

// Fingerprint
window.__webToApkAuth = window.__webToApkAuth || {};
window.__webToApkAuth['unlock'] = function (json) {
  var r = JSON.parse(json);
  if (r.ok) showMyApp();
};
WebToApk.authenticateBiometricEx('unlock', JSON.stringify({
  title: 'Unlock',
  allowDeviceCredential: true
}));

6Make your page fill the screen#

When you turn on Fullscreen, the app hides the Android bars for you. But add this CSS so your content is not hidden under the phone's notch.

Important: Fullscreen hides the ANDROID bars (clock, battery, back buttons). Your app's own coloured bar at the top is a different switch called 'Show top bar'. If you still see a bar after turning on Fullscreen, that is the one to turn off.

Also: test your app rotated. The notch moves to the side in landscape.

body {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  min-height: 100vh;   /* old phones */
  min-height: 100dvh;  /* correct when bars are hidden */
}

7Downloads keep the filename you chose#

Your normal download code already works. When your page saves a file - a PDF report, a CSV export, a backup - the app catches it and opens the Android 'Save as…' sheet with YOUR filename already filled in.

This covers every usual way of doing it: <a download="...">, a.click() from code, html2pdf / jsPDF, FileSaver.js saveAs(), and window.open() on a blob URL. A File object brings its own name with it.

If you would rather ask directly instead of building an <a> tag, use AppMint.downloadFile().

One thing to know: the name lives only in your page's JavaScript - Android never sees it on its own. So set download= (or pass a name) every time, or the file is saved as 'download'.

// The usual way β€” saves as Site_Report_2026-08.pdf
const blob = await html2pdf().from(el).outputPdf('blob');
const a = document.createElement('a');
a.download = 'Site_Report_2026-08.pdf';
a.href = URL.createObjectURL(blob);
a.click();

// Or ask directly
AppMint.downloadFile(base64String, 'Ledger_Q3.csv', 'text/csv');
AppMint.saveBlob(myBlob, 'Backup.json');

8Make the Back button do what YOUR app expects#

If your app changes screens by showing and hiding elements (no URL changes, no history.pushState), Android's Back button cannot see those screens - its history is empty, so Back exits the app from anywhere.

Register a back handler and decide yourself: return true when you handled the press (closed a menu, went back a screen), return false to let the normal behaviour run - page history back, then the exit confirmation, then exit.

Apps that use history.pushState for every screen do not need this: Back already walks their history. And don't worry about freezing the app - if your handler ever hangs, the phone's Back keeps working natively after a short moment.

AppMint.setBackHandler(() => {
  if (isMenuOpen)   { closeMenu();  return true; }   // consumed
  if (screen !== 'home') { goTo('home'); return true; }
  return false;   // nothing open β€” normal exit behaviour
});

// Or listen instead: e.preventDefault() consumes the press
window.addEventListener('appmint:back', e => {
  if (closeTopmost()) e.preventDefault();
});

Turn on 'Receive shares from other apps' in the build wizard and tick what your app accepts: text and links, data files (JSON, CSV, TXT, XML, Markdown, GPX, TCX, FIT), photos, videos, audio, documents (PDF, Word, Excel, PowerPoint). Your app then appears in the Share menu of every other app for exactly those kinds - a notes app for text, a player for videos.

A link or text (sharing a YouTube video from the YouTube app sends its link) is read with WebToApk.getSharedText() and announced as appmint:shared. A file is asked for with AppMint.getOpenedFile() - ask whenever you are ready, the file waits for you. This matters for React, Vue and Angular apps: they finish starting AFTER the page loads, so an app that only listened for the event used to miss the file and just show its home screen.

f.kind tells you what arrived: image, video, audio, document or data. Photos, videos and audio come as f.url - a streamable, seekable address you put straight into <video src>, <img src> or <audio src>, so a 300 MB clip never has to fit in memory; f.getFile() fetches the File when you really need the bytes. Data files and documents also come with f.file, plus f.text for text formats and f.base64 for small binaries as shortcuts. A photo shared with a caption delivers the caption as shared text too.

// A link or text someone shared
const shared = JSON.parse(WebToApk.getSharedText() || 'null');
if (shared) saveLink(shared.url || shared.text);
window.addEventListener('appmint:shared', e => saveLink(e.detail.url || e.detail.text));

// A photo, video, audio clip, document or data file β€” ask on startup, however late
const f = await AppMint.getOpenedFile();   // null if opened normally
if (f) {
  if (f.kind === 'video')         player.src = f.url;                 // streams, seekable
  else if (f.kind === 'image')    img.src = f.url;
  else if (f.kind === 'document') await upload(await f.getFile());   // a real File
  else importActivity(f.text ?? await f.file.text());               // data files
}

// Or listen, if you prefer
window.addEventListener('appmint:fileopen', e => handle(e.detail));

10Bluetooth sensors - standard Web Bluetooth#

Heart-rate straps, cadence and speed pods, power meters, smart trainers, scales - anything that speaks Bluetooth LE.

Use navigator.bluetooth, exactly the same code you would write for Chrome on a desktop. AppMint provides it inside the app; a plain Android WebView has none, which is why this code does nothing in other app builders.

The device chooser is drawn by the app, like the browser's - your page can only reach the device the user picked.

Turn on the Bluetooth permission in Step 4 (Access). If your code mentions navigator.bluetooth, AppMint turns it on for you when you pick your ZIP.

Not available: getDevices(), watchAdvertisements() and requestLEScan() - they reject with NotSupportedError, so check before using them.

const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: ['heart_rate'] }]
});
const server  = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const chr     = await service.getCharacteristic('heart_rate_measurement');

chr.addEventListener('characteristicvaluechanged', e => {
  const v = e.target.value;   // a DataView
  const bpm = (v.getUint8(0) & 1) ? v.getUint16(1, true) : v.getUint8(1);
  document.getElementById('bpm').textContent = bpm;
});
await chr.startNotifications();

device.addEventListener('gattserverdisconnected', () => showReconnect());

11Photo upload - the Android photo picker, no permission#

Use a normal file input. Nothing else to add.

In the app, <input type="file" accept="image/*"> opens the Android photo picker: the user's photos in a grid, and your page gets only the ones they choose. No storage permission, no 'Allow access to photos?' popup. It works on old phones too - Google Play services installs the picker there.

  • accept="image/*" - photos only
  • accept="video/*" - videos only
  • accept="image/*,video/*" - both
  • add multiple - the user can pick several
  • add capture="environment" - opens the camera instead (needs Camera ticked in Step 4 (Access))

Mix in other files (accept="image/*,.pdf") and the normal file picker opens instead, so the PDF can be chosen too.

<input type="file" id="photos" accept="image/*" multiple>

<script>
document.getElementById('photos').onchange = function (e) {
  for (const file of e.target.files) {
    const img = document.createElement('img');
    img.src = URL.createObjectURL(file);
    document.body.appendChild(img);
  }
};
</script>

12Scan a QR code or barcode - one line#

AppMint.scanCode() opens Google's scanner screen. The user points the camera, and you get the code back.

No camera permission. No video element. Nothing to draw. The scanner zooms in by itself for small codes. The first time on a phone, Google Play services downloads the scanner (a few seconds).

The answer has value (the text in the code) and format (qr_code, ean_13, …). For common QR codes you also get the parts ready: url, wifi (ssid, password), phone, email, sms, geo, contact.

If ok is false, code tells you why: cancelled (the user closed it), unavailable (no Google Play services, or the download failed), busy (a scan is already open).

AppMint.scanCode only exists inside the app - check for it first.

async function scan() {
  if (!window.AppMint || !AppMint.scanCode) return;   // in a browser

  const r = await AppMint.scanCode({ formats: ['qr_code'] });
  if (!r.ok) return;                                  // closed, or no scanner

  console.log(r.value);          // the text inside the code
  if (r.url) location.href = r.url.url;
}

// Any code type: AppMint.scanCode()
// Let the user type it when it will not scan: AppMint.scanCode({ manualInput: true })

13Passkeys - sign in with fingerprint or face, no password#

If your website already has passkeys, they now work inside your app too. A normal Android WebView cannot do passkeys; AppMint connects navigator.credentials to Android's passkey manager, so libraries like SimpleWebAuthn, Hanko, Corbado or Auth0 work unchanged.

You need a server that makes passkey options and checks the answer (for example SimpleWebAuthn on your backend). A passkey cannot be checked inside the page.

Two things to set up once:

  1. Your website must say it trusts the app. Put the file from AppMint.passkey.assetLinks() at https://YOUR-DOMAIN/.well-known/assetlinks.json. Take it from the app installed FROM Google Play - Play signs your app with its own key, so the file from a test APK will not match Play users. The domain must be your server's rpID, exactly (www and no-www are different).
  1. Your server must accept the app's origin. The answer from the app says origin 'android:apk-key-hash:…' instead of https://… - get that value from AppMint.passkey.origin() and add it to your server's allowed origins.

Errors: NotAllowedError = the user cancelled or has no passkey on this phone. InvalidStateError = this passkey already exists. SecurityError = assetlinks.json does not list the app.

Passkeys need Android 9 or newer with Google Play services - AppMint.passkey.isAvailable() tells you. Passkey autofill in a text field (mediation: 'conditional') is not available in apps; start sign-in from a button.

// Your normal passkey code works as it is:
const cred = await navigator.credentials.get({ publicKey: options });

// Or with your server's JSON options (SimpleWebAuthn style):
const options = await (await fetch('/passkey/login-options')).json();
const answer = await AppMint.passkey.get(options);
await fetch('/passkey/login-verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(answer)
});

// Once, on the Play-installed app β€” copy these to your server setup:
console.log(JSON.stringify(AppMint.passkey.assetLinks(), null, 2));
console.log(AppMint.passkey.origin());

14Home-screen widget - show your data on the home screen#

Your app has a home-screen widget. The user adds it from the phone's widget list (touch and hold the home screen, then Widgets), or you open the Add sheet with AppMint.widget.requestPin().

The widget stays empty ("Nothing to show yet") until your page fills it. It is a small Android card, not a web page, so it cannot show your HTML. You give it a title, one big value, up to six short lines and colours.

Call AppMint.widget.set() when the app opens, and again every time the data it shows changes. The widget keeps the last card, even after a restart. route is where a tap on the widget opens your app.

function updateWidget() {
  if (!window.AppMint || !AppMint.widget) return;   // in a browser

  AppMint.widget.set({
    title: 'Today',
    value: '3 events',
    lines: ['09:00 Dentist', '13:00 Lunch with Sam', '18:30 Gym'],
    route: '/',
    bg: '#0E7C86', ink: '#FFFFFF', accent: '#FFE08A'
  });
}

updateWidget();   // on start-up, and after every change

// An "Add to home screen" button:
async function addWidget() {
  if ((await AppMint.widget.placedCount()) > 0) return;
  await AppMint.widget.requestPin();
}

15Copy protection and the long-press menu#

Two switches in Step 3 (Integrate), under Display Options. Both are off until you turn them on.

  • Long-press menu on links and images: press and hold a link or a picture to open, copy, share or save it. Off by default, so a long press on a link does nothing. A page can still refuse the menu on one element by cancelling the contextmenu event.
  • Copy protection: nobody can select, copy or cut the text in your app. Typing in forms still works. It also keeps the long-press menu off.

Your page can tell: AppMint.copyProtection is true when protection is on, and missing when it is off (and in a browser). It is read-only. Hide your own Copy buttons when it is on, because they would copy nothing.

const protectedApp = !!(window.AppMint && window.AppMint.copyProtection === true);

// A Copy button would copy nothing, so hide it
document.querySelectorAll('[data-copy-button]').forEach(btn => {
  btn.hidden = protectedApp;
});

16{exampleCount} ready-made examples - open them here#

Working code you can copy: send an SMS, read contacts, show a notification with a picture, fingerprint lock, phone information, saving files, Bluetooth sensors, and more. Each one is short and explained in simple words.

Opens inside AppMint. You can search it, and every code block has a Copy button.

The same screen also has 'All methods' - all {methodCount} things your app can call, and every one comes with a working example you can copy. That list is built from the app runtime itself, so it always matches exactly what your app can do.

17Save the examples to your phone or computer#

Saves three files to Downloads/AppMint/Guides:

  • Examples - the {exampleCount} examples above
  • Method list - all {methodCount} methods
  • index.html - a ready test page

The test page has buttons for device info, vibration, notifications and the contact picker. Build it as an app to see everything working, then change it into your own app. It also opens fine in a normal browser - nothing breaks, it just says it is not inside the app.

In the app, this step's button saves these to your phone. Here they are direct downloads.

AppMint-examples.md MD Β· 48 KB AppMint-all-methods.md MD Β· 1.4 MB index.html HTML Β· 6 KB

18Alarm clock that rings over the lock screen#

Tick Alarm clock in the Access step. scheduleNotificationEx with alarm: true rings with your sound until the user dismisses or snoozes, even when the app is closed. Repeat can be daily, weekly, weekdays, monthly or yearly, or pick days of the week.

Before scheduling, ask getAlarmSupport(). It answers 'ready', 'not_enabled', 'needs_exact_alarm' or 'needs_notification_permission'. Then stopAlarm(id), snoozeAlarm(id, minutes) and isAlarmRinging(id) control a ringing alarm.

const B = window.WebToApk;
if (B && B.getAlarmSupport && B.getAlarmSupport() === 'ready') {
  B.scheduleNotificationEx('wake', JSON.stringify({
    at: firstAtMs, alarm: true, title: 'Wake up', repeat: 'weekdays', sound: '/audio/alarm.mp3'
  }));
}
document.getElementById('snooze').onclick = () => B.snoozeAlarm('wake', 10);

19System volume#

getVolume(stream) reads media, alarm, ring, notification or call volume as JSON. setVolume(stream, percent, showUi) changes it. watchVolume(true) sends an appmint:volume event on every change. Silencing the ring volume needs Do Not Disturb access: requestDndAccess().

if (window.WebToApk && WebToApk.getVolume) {
  const v = JSON.parse(WebToApk.getVolume('alarm'));
  if (v.percent === 0) alert('Alarm volume is off');
  window.addEventListener('appmint:volume', (e) => console.log(e.detail.stream, e.detail.percent));
  WebToApk.watchVolume(true);
}

20Background service, background location, geofences and tasks#

startBackgroundService keeps your page, audio and timers running while the user is away, with a notification. requestBackgroundLocation asks for 'Allow all the time'. addGeofence fires enter, exit or dwell with the app closed. AppMint.background.register runs your own page code every 15 minutes or more. All of them start again after a phone restart.

These need their switch in the wizard (Access step); a call answers { ok: false, reason: 'not_enabled' } without it.

const r = JSON.parse(WebToApk.startBackgroundService(JSON.stringify({ title: 'Syncing', text: 'Keeping data fresh' })));
if (!r.ok) console.warn(r.reason);
if (AppMint.background.isBackgroundRun()) {
  window.addEventListener('appmint:background', async (e) => {
    await refreshData();
    AppMint.background.done(e.detail.runId, { ok: true });
  });
} else {
  AppMint.background.register('refresh', { intervalMinutes: 60 });
}

21Crash reports#

Tick Crash reports in the Integrate step. Uncaught errors are recorded for you. logError(message, stack) records one you caught, setCrashUserId(id) tags reports with your own user id (never an e-mail), and getLastCrash() returns the newest crash as JSON, or an empty string. Read the reports in Update the App, then your app, then Crash reports.

if (window.WebToApk && WebToApk.logError) {
  try { risky(); } catch (err) { WebToApk.logError(err.message, err.stack || ''); }
  WebToApk.setCrashUserId('u_42');
  const last = WebToApk.getLastCrash();   // '' when none
}

22Device integrity and Play Integrity#

Tick Device integrity checks in the Integrate step. getDeviceIntegrity() returns root, emulator and debugger signs as JSON; it is a warning, not proof. For a verdict you can trust, call requestIntegrityToken(nonce, '') and send the token from the appmint:integrity event to YOUR server, which asks Google Play for the verdict.

const d = JSON.parse(WebToApk.getDeviceIntegrity());
if (d.rooted || d.emulator) alert('This phone is modified');
window.addEventListener('appmint:integrity', (e) => sendToServer(e.detail.token));
WebToApk.requestIntegrityToken(serverNonceBase64, '');

23Alternate app icons and TV#

Add up to 4 alternate icons in the Basic step. setAppIcon(name) switches the launcher icon ('default' goes back), getAppIcon() names the current one and getAvailableIcons() lists them. isTv() tells you the app is on a television, so you can show a remote-friendly layout.

if (window.WebToApk && WebToApk.setAppIcon) WebToApk.setAppIcon('dark');
if (window.WebToApk && WebToApk.isTv && WebToApk.isTv()) {
  document.documentElement.classList.add('tv');
}

24Facebook sign-in, analytics and RevenueCat#

Facebook sign-in is supported. In the Integrate step enter your Facebook App ID and Client Token; the login opens in a browser tab and returns to the app. Call AppMint.facebook.login(['public_profile', 'email']) and send the token to your server. AppMint.analytics (Firebase) and AppMint.facebook.logEvent (Meta App Events) record only after the user has agreed. AppMint.revenuecat handles subscriptions with your public Android key.

const r = await AppMint.facebook.login(['public_profile', 'email']);
if (r.ok) await fetch('/api/login/facebook', { method: 'POST', body: JSON.stringify({ token: r.accessToken }) });
AppMint.analytics.log('share', { method: 'whatsapp' });
const offerings = await AppMint.revenuecat.getOfferings();

25Text from photos, PDFs and widgets#

AppMint.textRecognition.recognize(image, { script }) reads text in Latin, Devanagari, Chinese, Japanese or Korean. AppMint.pdf.fromPage() and fromHtml(html, options) make a real PDF with no print dialog. AppMint.widget.set(card) fills the home-screen widget.

const r = await AppMint.textRecognition.recognize(file, { script: 'devanagari' });
const pdf = await AppMint.pdf.fromHtml('<h1>Invoice</h1>', { fileName: 'invoice', save: 'share' });
await AppMint.widget.set({ title: 'Today', value: '3 events', route: '/calendar' });

26Health data and Bluetooth beacons#

AppMint.health reads and writes steps, heart rate, sleep, weight and exercise through Health Connect; tick Health data and the types in the Access step. AppMint.beacons.startScan() finds iBeacon and Eddystone beacons and sends appmint:beacon events.

const a = await AppMint.health.isAvailable();
if (a.available) {
  await AppMint.health.requestPermissions({ read: ['steps'] });
  const steps = await AppMint.health.read('steps', { start: new Date(Date.now() - 864e5), end: new Date(), aggregate: true });
}
window.addEventListener('appmint:beacon', (e) => console.log(e.detail));
await AppMint.beacons.startScan();

27Ready-made plugins: SQLite, notifications, push, scanner, contacts#

On pages the app serves itself, window.Capacitor.Plugins holds CapacitorSQLite (a private database), LocalNotifications, PushNotifications, CapacitorBarcodeScanner and Contacts. Check that the plugin exists first: a Website-mode app has window.Capacitor only on its own site's pages, never on other domains.

const P = window.Capacitor && window.Capacitor.Plugins;
if (P && P.CapacitorBarcodeScanner) {
  const s = await P.CapacitorBarcodeScanner.scanBarcode({ hint: 17 });
  console.log(s.ScanResult);
}
if (P && P.LocalNotifications) await P.LocalNotifications.requestPermissions();

This page is generated from the Integration Guide inside the app itself, so it says exactly what the current build says. Read it as Markdown.

Checked against the shipped guide on 2026-10-22.