5-minute Hello World
Read a heart-rate characteristic on iOS Safari with the same code you'd ship to Chrome or Edge. No vendor prefix, no fallback branch, nothing to conditionally import.
import '@beacio/core/auto';
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }]
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const char = await service.getCharacteristic('heart_rate_measurement');
const value = await char.readValue();
console.log('bpm', value.getUint8(1));
Same code, every browser. The import is a no-op
on Chrome and Edge — their native
navigator.bluetooth is used unchanged. On iOS Safari
the beacio extension provides the same API itself, so the import
line is optional — drop it and the remaining code still runs.
Zero-config: no code changes
With the beacio extension installed, an existing Web Bluetooth site
works with no code changes.
navigator.bluetooth and
window.BluetoothUUID are available on every HTTPS page
before your first script runs.
The full W3C surface is already there:
requestDevice(), getDevices(),
getAvailability(),
navigator.permissions.query({ name: 'bluetooth' }),
EventTarget plus the
onadvertisementreceived /
ongattserverdisconnected handlers, and device-event
bubbling. The only user step is a one-time per-origin activation tap
the first time a site asks for Bluetooth.
Everything below is an optional enhancement on top of that baseline:
-
Install detection and install-prompt UX
(
@beacio/core/detect) — guide iOS Safari users who do not have the extension yet. -
Auto-reconnect supervision
(
@beacio/core/auto) — reconnects with backoff after an unintended disconnect and re-arms active notifications.
Good to know.
requestDevice() must be called from a user gesture
(tap or click). The API is secure-context only — HTTPS or
localhost. requestLEScan() (Web Bluetooth
Scanning draft) is on navigator.bluetooth. The iOS-only
extras peripheral, backgroundSync and
getCapabilities() live on the vendor surface
window.beacioIOS.
Quick Start
beacio brings the Web Bluetooth API to iOS Safari via a Safari extension and companion app. The paths below are optional enhancements on top of what the extension already installs — reach for them when you want install-prompt UX or React bindings.
Option 1: Core Auto Polyfill (optional)
npm install @beacio/core
import '@beacio/core/auto';
// Your existing Web Bluetooth code stays standard
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }]
});
On Safari iOS, the package works with the beacio extension when it is installed. On browsers with native Web Bluetooth support, it stays out of the way.
Option 2: CDN (no build step)
Drop a single <script> tag — no npm, no bundler,
no toolchain. The script mounts the polyfill at
navigator.bluetooth and stays out of the way on Chrome
and Edge.
<script src="https://beacio.com/beacio.js"></script>
// Then use the standard Web Bluetooth API
const device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true
});
Option 3: React SDK
npm install @beacio/react
import { BeacioProvider, useBluetooth } from '@beacio/react';
import '@beacio/core/auto';
function App() {
return (
<BeacioProvider>
<MyBluetoothApp />
</BeacioProvider>
);
}
Option 4: Add Install Detection Later
If you need install-state UX, analytics, or a custom missing-extension flow, layer that on after the core path is working.
How It Works
There are two parts, and only the first one is required:
-
beacio Safari extension — a free app for iPhone.
Install it from the App Store, then turn it on in Safari; from then
on, pages in Safari get the standard
navigator.bluetoothandwindow.BluetoothUUID. Bluetooth stays idle until a page asks for a device. - Web SDK (optional) — an npm package and CDN script for install detection and prompts, onboarding UI, auto-reconnect, and React helpers on top of the standard API
When a user visits your site on iOS Safari:
- Your page calls the standard Web Bluetooth API (e.g.
navigator.bluetooth.requestDevice()). - With beacio enabled in Safari, the browser prompts the user for Bluetooth permission and connects to the device directly. Existing Web Bluetooth sites work with no code changes.
- If the extension is not yet installed or enabled, your site can guide the user to install beacio using the optional detection SDK.
Zero lock-in. beacio provides the standard Web Bluetooth API. In browsers with built-in support like Chrome and Edge, your standard code runs natively.
Browser Support
| Browser | Web Bluetooth | beacio Action |
|---|---|---|
| Safari iOS 18.0+ | No (Apple blocks it) | Extension provides full support |
| Chrome 56+ | Native | No-op (native works) |
| Edge 79+ | Native | No-op |
| Firefox | No | Not supported |
Common Device Profiles
Any standard GATT device is reachable through the standard Web Bluetooth API. These are the protocols developers ask for most — each works on iPhone Safari with beacio, same as on Chrome and Edge.
Maker serial over BLE
Espruino, Bangle.js, Puck.js, micro:bit and ESP32 over Nordic
UART; Arduino and cheap modules over HM-10 /
0xFFE0. The serial service that powers most maker
pages.
Fitness & heart rate
Heart rate (0x180D), power meters, speed/cadence
and trainers — live web dashboards on iPhone, no extra app
to install.
Environmental sensing
Temperature, humidity and air-quality sensors over Environmental
Sensing (0x181A) — read them straight from a
web page.
Battery & device info
Read battery level (0x180F) and device information
from nearly any BLE peripheral — shipped profiles,
verified on a physical iPhone.
Scales, health & find-me tags
Weight scales, blood pressure, pulse oximeters and “beep my tag” find-me alerts — the standard SIG profiles a web app can read directly.
Smart locks & access state
Web check-in flows and clear-text lock state in real Safari. (Vendor unlock commands stay in dedicated app code — we don’t claim one-tap unlock.)
Bluetooth device makers
Already ship a native iOS app for your hardware? Offer a web app alongside it — same device, no second native build.
Live Examples
Each folder under examples/ in the repo is a runnable
sample. The three portable examples use
navigator.bluetooth directly — the same source runs on
Chrome and Edge. The flagship ble-e2e-chat demo uses
window.beacioIOS.peripheral for the iOS-only
GATT server capability.
-
ble-e2e-chat — flagship iOS demo. Two
phones on an airplane chat end-to-end encrypted over BLE with no
internet, using
window.beacioIOS.peripheralto act as a GATT server. The showcase for the iOS-extensions tier. -
heart-rate-monitor — vanilla JS. Reads and
streams the standard
heart_rate_measurementcharacteristic. Runs unchanged on Chrome and Edge. - web-scanner — generic BLE scanner + extension-detector showcase. Useful as a smoke test for your own device.
@beacio/core/auto
The core package is an optional enhancement layer for production apps. The extension already installs the standard Web Bluetooth API; the import adds install detection and auto-reconnect supervision.
Basic Usage
npm install @beacio/core
import '@beacio/core/auto';
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['battery_service'] }]
});
What It Does
- Detects whether the browser already supports Web Bluetooth
- Works with the beacio Safari extension when it is installed
-
Polyfills
navigator.bluetoothfor standard Web Bluetooth calls - Keeps your app code aligned with the Web Bluetooth API instead of a custom runtime API
Recommended workflow: verify your device in the
live scanner first, then add
import '@beacio/core/auto'; to your app once you know
the BLE radio path is healthy.
React SDK
The @beacio/react package provides React hooks and
components on top of the same standard Web Bluetooth model.
npm install @beacio/react
Provider Setup
Wrap your app with BeacioProvider after loading the
core polyfill:
import { BeacioProvider } from '@beacio/react';
import '@beacio/core/auto';
function App() {
return (
<BeacioProvider>
<MyBluetoothApp />
</BeacioProvider>
);
}
Using Hooks
import { usebeacio, useDevice, useCharacteristic } from '@beacio/react';
function HeartRateMonitor() {
const { requestDevice, isAvailable } = usebeacio();
const handleConnect = async () => {
const device = await requestDevice({
filters: [{ services: ['heart_rate'] }],
});
// device is a standard BluetoothDevice
};
return (
<button onClick={handleConnect} disabled={!isAvailable}>
Connect Heart Rate Monitor
</button>
);
}
Available Hooks
| Hook | Purpose |
|---|---|
usebeacio() |
Core context: availability, extension status, requestDevice |
useBluetooth() |
Simplified BLE access with auto-detection |
useDevice() |
Device connection state, services, connect/disconnect |
useCharacteristic() |
Read, write, subscribe to a GATT characteristic |
useNotifications() |
Subscribe to characteristic notifications with history |
useScan() |
BLE scanning with device list management |
useProfile() |
Typed helpers for common BLE device profiles |
Components
| Component | Purpose |
|---|---|
<DeviceScanner /> |
Pre-built device scanner UI |
<ServiceExplorer /> |
GATT service/characteristic browser |
<InstallationWizard /> |
Optional missing-extension guidance UI |
Optional install UX. The React package does not require an API key-first setup. Add the detection package only if you want install-state messaging or a more guided missing-extension flow.
Install Detection
@beacio/detect is an optional package for extension
detection and missing-extension UX. Use it when your product needs
install-aware messaging; skip it if the core polyfill is enough.
npm install @beacio/detect
Programmatic API
import { initBeacio } from '@beacio/detect';
await initBeacio({
operatorName: 'FitTracker',
banner: {
mode: 'sheet', // 'sheet' (default) or 'banner'
dismissDays: 14, // suppress after dismiss
},
onReady() { /* extension installed */ },
onNotInstalled() { /* show fallback */ },
});
Auto-Init (Zero Code)
Import the auto module and configure via meta tags:
<meta name="beacio-name" content="My App">
<script type="module">
import '@beacio/core/auto';
import '@beacio/detect/auto';
</script>
React Integration
import { BeacioProvider, useBeacio } from '@beacio/react';
function Layout({ children }) {
return (
<BeacioProvider
config={{ operatorName: 'FitTracker' }}>
{children}
</BeacioProvider>
);
}
Detection API
isIOSSafari()
Returns true if the current browser is Safari on iOS
(including iPad).
import { isIOSSafari } from '@beacio/detect';
if (isIOSSafari()) {
// Running on iOS Safari
}
isExtensionInstalled()
Returns a Promise<boolean> that resolves after
checking for the extension (waits up to 2 seconds).
import { isExtensionInstalled } from '@beacio/detect';
const installed = await isExtensionInstalled();
initBeacio(options)
Main entry point. Detects iOS Safari, checks extension state, and lets you hook in custom install guidance if needed.
| Option | Type | Description |
|---|---|---|
operatorName |
string? |
App name for install prompt |
banner |
object | false |
Install guidance configuration or false to
disable
|
onReady |
() => void |
Called when extension is detected |
onNotInstalled |
() => void |
Called when extension is NOT installed |
Install UX Options
Configure how missing-extension guidance appears when you use the optional detection package.
| Option | Type | Default | Description |
|---|---|---|---|
mode |
'sheet' | 'banner' |
'sheet' |
Bottom sheet (iOS-native) or lightweight banner bar |
position |
'top' | 'bottom' |
'bottom' |
Bar position (banner mode only) |
text |
string |
Auto-generated | Custom banner text |
buttonText |
string |
'Start Setup' |
CTA button text |
operatorName |
string |
Page title / hostname | Your app name in the prompt |
startOnboardingUrl |
string? |
Current page | Preferred setup or help URL for the CTA |
appStoreUrl |
string? |
Legacy fallback | Legacy CTA destination override; supported but not recommended as the primary path |
dismissDays |
number |
14 |
Days to suppress after dismiss |
style |
Record<string, string> |
— | Custom CSS for banner bar mode |
React Hooks
usebeacio()
Returns the core beacio context. Must be used inside
<BeacioProvider>.
| Property | Type | Description |
|---|---|---|
isAvailable |
boolean |
Whether Bluetooth is available |
isExtensionInstalled |
boolean |
Whether the beacio extension is detected |
isLoading |
boolean |
Whether detection is in progress |
isScanning |
boolean |
Whether a BLE scan is active |
devices |
BluetoothDevice[] |
Discovered devices |
error |
Error | null |
Last error |
requestDevice() |
function |
Request a BLE device (standard API) |
getDevices() |
function |
Get previously paired devices |
requestLEScan() |
function |
Start a BLE scan |
stopScan() |
function |
Stop the current scan |
useBeacio()
From @beacio/react. Returns the full provider context,
including extension detection state for install-aware UI.
| Property | Type | Description |
|---|---|---|
isExtensionInstalled |
boolean |
Whether the Beacio extension is installed |
extensionInstallState |
'not-installed' | 'installed-inactive' | 'active' |
Detailed install state for onboarding vs active flows |
isAvailable |
boolean |
Whether navigator.bluetooth is available |
Events
The SDK dispatches custom events on the window object:
| Event | When |
|---|---|
beacio:ready |
Extension detected and ready |
beacio:notinstalled |
Extension NOT installed on iOS Safari |
beacio:extension:ready |
The extension's API is ready on this page (fired by the extension itself) |
// Listen for extension detection
window.addEventListener('beacio:ready', () => {
console.log('Extension is active!');
});
Background Sync
iOS-only. Background Sync runs through the beacio
companion app, and notification-based workflows require
notification permission. Call
requestPermission() before registering characteristic
alerts or beacon scans.
Use navigator.beacio.backgroundSync to keep approved
BLE devices active while Safari is backgrounded and to deliver iOS
notifications for important BLE events.
requestPermission()
Call this from a direct user gesture. It resolves to
'granted', 'denied', or
'prompt'.
document.querySelector('#enable-background-sync')?.addEventListener('click', async () => {
const permission = await navigator.beacio.backgroundSync.requestPermission();
if (permission !== 'granted') {
console.log('Background notifications not enabled:', permission);
return;
}
console.log('Background notifications enabled');
});
Background Connection
requestBackgroundConnection({ deviceId }) asks the
companion app to keep a granted device connected in the background.
It is silent by design and does not show an iOS notification on its
own.
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }]
});
await device.gatt.connect();
await navigator.beacio.backgroundSync.requestBackgroundConnection({
deviceId: device.id
});
Characteristic Notifications
registerCharacteristicNotifications(options) fires an
iOS notification when a characteristic changes while Safari is
backgrounded.
condition is required and is evaluated on the device before
iOS notification delivery.
| Option | Type | Description |
|---|---|---|
deviceId |
string |
Device ID from a previous requestDevice() call in
the current tab
|
serviceUUID |
BluetoothServiceUUID |
Service containing the characteristic to watch |
characteristicUUID |
BluetoothCharacteristicUUID |
Characteristic to watch for value changes |
condition |
NotificationCondition |
Condition that gates notification delivery |
template |
NotificationTemplate |
Notification content shown by iOS |
replyAction |
ReplyActionConfig? |
Optional inline reply configuration |
cooldownSeconds |
number? |
Minimum seconds between notifications for the same
registration (default 5)
|
NotificationTemplate
| Property | Type | Description |
|---|---|---|
title |
string |
Notification title. Supports placeholders |
body |
string |
Notification body. Supports placeholders |
url |
string |
HTTPS URL opened when the user taps the notification |
sound |
boolean? |
Play the default notification sound. Defaults to
true
|
| Placeholder | Meaning |
|---|---|
{{device.name}} |
Device name |
{{device.id}} |
Device ID |
{{value.hex}} |
Characteristic value as lowercase hex |
{{value.utf8}} |
Characteristic value decoded as UTF-8 |
{{value.int16be}} |
First two bytes as signed big-endian int16 |
{{value.int32be}} |
First four bytes as signed big-endian int32 |
{{timestamp}} |
ISO 8601 timestamp for the received value |
ReplyActionConfig
| Property | Type | Description |
|---|---|---|
actionTitle |
string |
Inline reply button label |
placeholder |
string? |
Input placeholder shown in the reply sheet |
Use friendly text here for forward compatibility. The initial release uses the standard iOS inline reply UI.
const messageAlerts = await navigator.beacio.backgroundSync.registerCharacteristicNotifications({
deviceId: device.id,
serviceUUID: '12345678-1234-1234-1234-123456789abc',
characteristicUUID: '87654321-4321-4321-4321-cba987654321',
condition: {
decode: 'uint8',
operator: 'changed',
threshold: 0
},
cooldownSeconds: 15,
template: {
title: 'Message from {{device.name}}',
body: '{{value.utf8}}',
url: 'https://example.com/messages',
sound: true
},
replyAction: {
actionTitle: 'Reply',
placeholder: 'Send a quick response'
}
});
Beacon Scanning
registerBeaconScanning(options) matches BLE
advertisements and delivers an iOS notification when a filter hits.
| Option | Type | Description |
|---|---|---|
filters |
BLEScanFilter[] |
One or more advertisement filters. At least one filter must
include services
|
cooldownSeconds |
number? |
Minimum seconds between notifications for the same detected
device (default 5)
|
template |
NotificationTemplate |
Notification content shown by iOS |
cooldownSeconds throttles how often iOS shows a
notification. It does not control scan frequency.
BLEScanFilter
| Property | Type | Description |
|---|---|---|
services |
BluetoothServiceUUID[]? |
Service UUIDs to match in advertisements |
namePrefix |
string? |
Optional device or advertisement name prefix |
Fast catch-up. Background beacon delivery on iOS is designed for quick catch-up rather than fixed real-time discovery. Use service UUID filters and sensible cooldowns for the most reliable alerts.
const beaconRegistration = await navigator.beacio.backgroundSync.registerBeaconScanning({
filters: [
{ services: ['feaa'] },
{
services: ['12345678-1234-1234-1234-123456789abc'],
namePrefix: 'Sensor-'
}
],
cooldownSeconds: 60,
template: {
title: 'Nearby BLE beacon',
body: '{{device.name}} is advertising nearby',
url: 'https://example.com/beacons',
sound: true
}
});
Managing Registrations
Use getRegistrations(), unregister(id),
and update(id, template) to manage registrations for
the current origin.
const registrations = await navigator.beacio.backgroundSync.getRegistrations();
for (const registration of registrations) {
console.log(registration.id, registration.type);
}
await navigator.beacio.backgroundSync.update(messageAlerts.id, {
title: 'New device message',
body: '{{value.utf8}}'
});
await navigator.beacio.backgroundSync.unregister(beaconRegistration.id);
BackgroundRegistration handle
| Property / Method | Type | Description |
|---|---|---|
id |
string |
Unique registration ID |
type |
'connection' | 'characteristic-notification' |
'beacon-scan'
|
Registration kind |
createdAt |
number |
Registration creation timestamp in Unix milliseconds |
lastTriggeredAt |
number? |
Last delivery timestamp, when available |
unregister() |
() => Promise<void> |
Remove this registration |
update() |
(template: Partial<NotificationTemplate>) =>
Promise<void>
|
Update title, body, URL, or sound |
Security notes
-
template.urlmust usehttps://and is only opened when it matches the same origin as the page that registered it. -
deviceIdis scoped to devices previously granted viarequestDevice()for the current site flow. -
Template values are sanitized before iOS renders the notification,
and only supported
{{...}}placeholders are interpolated.
Complete example
const permission = await navigator.beacio.backgroundSync.requestPermission();
if (permission !== 'granted') {
throw new Error('Enable notifications to use background BLE alerts');
}
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }]
});
await device.gatt.connect();
const keepAlive = await navigator.beacio.backgroundSync.requestBackgroundConnection({
deviceId: device.id
});
const heartRateAlerts = await navigator.beacio.backgroundSync.registerCharacteristicNotifications({
deviceId: device.id,
serviceUUID: 'heart_rate',
characteristicUUID: 'heart_rate_measurement',
condition: {
decode: 'uint8',
operator: 'gt',
threshold: 120
},
cooldownSeconds: 30,
template: {
title: 'Heart rate update',
body: '{{device.name}} sent {{value.hex}} at {{timestamp}}',
url: 'https://example.com/monitor',
sound: true
}
});
const activeRegistrations = await navigator.beacio.backgroundSync.getRegistrations();
console.log(activeRegistrations.map(({ id, type }) => ({ id, type })));
await heartRateAlerts.update({
body: 'Latest payload from {{device.name}}: {{value.hex}}'
});
// Later, when you no longer need background delivery:
await heartRateAlerts.unregister();
Troubleshooting
Extension not detected after installation
The user may have enabled the extension but not granted website permissions. They need to:
- In Safari, tap the Page Menu button at the left end of the address bar
- Tap the beacio icon (may have a warning badge)
- Choose "Allow on Every Website"
"Allow for One Day" expires silently. If a user chose "Allow for One Day" instead of "Allow on Every Website," the extension will stop working the next day with no notification. Guide users to "Allow on Every Website" in your onboarding.
Extension works on some sites but not others
The user may have chosen per-site permissions. They need to grant "Allow on Every Website" for consistent behavior.
HTTPS required
Web Bluetooth requires a secure context. Ensure your site uses HTTPS
(or localhost for development).
navigator.bluetooth is undefined
- On iOS Safari: the extension is not installed or not granted permissions, or the page is not HTTPS
- On Firefox: Web Bluetooth is not supported
-
On Chrome/Edge: should work natively — check
chrome://flags/#enable-experimental-web-platform-features
Privacy
beacio is designed with privacy as a core principle:
- All BLE communication stays on-device. Bluetooth traffic is processed entirely on your iPhone between your browser and your device. No BLE data is ever proxied through or sent to a server.
- No browsing history is stored or transmitted. beacio does not inspect, retain, or transmit page content, URLs, or browsing history to deliver Bluetooth connectivity.
- Optional developer analytics stay minimal. When a developer explicitly enables SDK analytics, the service records limited install-state events such as hostname, user agent, timestamp, and the integration identifier tied to that developer account.
- Proprietary app, public docs. The beacio app and Safari extension are proprietary software; the npm SDK packages ship under their own licenses (see each package). Setup, usage, and API documentation are published in full for users and developers.
"Allow on Every Website" does NOT mean "always watching." It means the extension is available on every website, but only activates when a site explicitly requests Bluetooth access via the Web Bluetooth API.
W3C Conformance
beacio implements the standard
W3C Web Bluetooth specification
on the standard navigator.bluetooth surface — in
the Safari extension and in the @beacio/core npm
package. The implementation was verified section by section against
the spec: every IDL member was traced end to end through the
implementation and compared with the normative spec text, and every finding was adversarially re-verified before being
accepted and fixed.
- Enforcement a page cannot bypass. Filter validation, permission grants, the GATT blocklist (generated from the W3C Web Bluetooth registries), and per-origin device IDs are enforced by beacio outside the reach of page JavaScript.
-
Full surface.
requestDevice,getDevices,getAvailability, GATT server / services / characteristics / descriptors,BluetoothUUID, advertising events, event bubbling, and Permissions Policy integration. - Honest about discretion. Where the spec leaves choices to the implementation (chooser presentation, permission lifetime, the separate LE Scanning draft), those choices are documented rather than glossed over.
Read the full interface-by-interface conformance matrix.