Developer Docs

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 extension mounts the polyfill at the same address, so the import line itself 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 installed on every HTTPS page at document_start, before your first script runs.

The full W3C surface is already there: requestDevice(), getDevices(), getAvailability(), 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.
  • navigator.permissions.query({ name: 'bluetooth' }) — an SDK-only shim; the extension deliberately does not patch navigator.permissions.
  • Parse-time capture on strict-CSP origins — where script-src omits 'unsafe-inline', the extension falls back to an async injected script, so the API is late but present. Code that reads navigator.bluetooth at parse time on such an origin still wants the CDN tag or the SDK import.
  • 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. Draft and vendor-prefixed members such as requestLEScan() are deliberately absent from navigator.bluetooth; they live on the iOS-only 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, the permissions shim, parse-time capture on a strict-CSP origin, 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 activates the extension-backed polyfill when available. 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

The system has three parts:

  1. Safari Extension — A free iOS app with a Safari Web Extension that bridges CoreBluetooth to web pages
  2. Content Script — Injected by the extension at document_start; it installs navigator.bluetooth and window.BluetoothUUID immediately, keeps the radio dormant, and activates the native beacio pipeline for the current tab when the user requests it
  3. Web SDK — Provides the Safari iOS polyfill, optional install detection, and React helpers

When a user visits your site on iOS Safari:

  1. Your page calls standard Web Bluetooth code
  2. If the extension is active on Safari iOS, beacio exposes navigator.bluetooth through the extension bridge
  3. If the extension is missing, you can show your own instructions or add the optional detection package

Zero lock-in. beacio polyfills the standard Web Bluetooth API. If the user has Chrome, Edge, or any browser with native support, the SDK stays out of the way. Your code is always standard.

Browser Support

Browser Web Bluetooth beacio Action
Safari iOS 26.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 bridge 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 superpower.

  • ble-e2e-chat — flagship iOS demo. Two phones on an airplane chat end-to-end encrypted over BLE with no internet, using window.beacioIOS.peripheral to act as a GATT server. The showcase for the iOS-extensions tier.
  • heart-rate-monitor — vanilla JS. Reads and streams the standard heart_rate_measurement characteristic. 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, the navigator.permissions shim, auto-reconnect supervision, and parse-time capture on strict-CSP origins.

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
  • Activates the Safari iOS extension bridge when available
  • Polyfills navigator.bluetooth for 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 for injection).

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 Extension content script injected (from 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 natively 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 Native 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.url must use https:// and is only opened when it matches the same origin as the page that registered it.
  • deviceId is scoped to devices previously granted via requestDevice() 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:

  1. In Safari, tap aA in the address bar
  2. Tap the beacio icon (may have a warning badge)
  3. Choose "Always Allow"
  4. Then "Always Allow on Every Website"

"Allow for One Day" expires silently. If a user chose "Allow for One Day" instead of "Always Allow," the extension will stop working the next day with no notification. Guide users to "Always Allow" in your onboarding.

Extension works on some sites but not others

The user may have chosen per-site permissions. They need to grant "Always 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 a strict-CSP origin (a script-src without 'unsafe-inline'): the extension falls back to an async injected script, so the API arrives shortly after parse. Read it inside your event handlers, or add the CDN tag / the @beacio/core/auto import to capture it at parse time.
  • 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 data processed locally. Bluetooth communication happens entirely on-device between the browser and CoreBluetooth. No data is ever proxied through a server.
  • No BLE data leaves the device. Bluetooth communication stays on-device between Safari, the extension, and CoreBluetooth. beacio does not proxy device traffic through a server.
  • No browsing history is stored or transmitted for BLE functionality. The extension uses Safari page integration to expose the API, but beacio does not inspect, retain, or transmit page content or browsing history to deliver Bluetooth connectivity.
  • Optional 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 product, public docs. beacio publishes detailed setup and API documentation, but the product should be evaluated as proprietary software today.

"Always 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 full W3C Web Bluetooth specification on the standard navigator.bluetooth surface — the injected polyfill, the native CoreBluetooth backend, and the @beacio/core npm polyfill. The implementation was verified section by section against the spec: every IDL member was traced through the JavaScript, protocol, and native layers and compared with the normative spec text, and every finding was adversarially re-verified before being accepted and fixed.

  • Native enforcement. Filter validation, permission grants, the GATT blocklist (generated from the W3C Web Bluetooth registries), and per-origin device IDs are enforced in the native layer — not in page-world 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.