Skip to main content

React Native library: JavaScript usage and API

Use the supplied React Native library to discover locks, manage BLE sessions, present unlock credentials, and monitor firmware updates. This guide shows the application flow first, followed by a reference for the library's public methods and events.

Proxy setup and device discovery​

Complete Option A or Option B of Route 1 before using these examples with @last-lock/link-mobile. For Routes 2 and 3, consult the native SDK guides and sample apps. Those routes do not provide this JavaScript API unless you build a compatible bridge.

Initialize, then start​

Initialize the library once, then choose the startup mode that matches your app's lifecycle:

CallBehavior
await initialize()Sets the Link hostname without scanning. On Android, it also prepares the native bridge and listeners; on iOS, it stores the hostname for startup.
await start()Starts BLE scanning and Proxy work. Android requests any missing runtime permissions and runs a foreground service. Avoid calling it on every render or discovery event; another call may restart the native session.
await startLimited()Starts the same flow as start on iOS. On Android, it requests foreground Bluetooth permissions and runs without a foreground-service notification. Use it for nearby-lock interactions while the app is visible, and call it again whenever the app returns to the foreground.

Await initialize before either startup method. By default, the SDK connects to plane.lastlock.com over TLS on port 443 and scans for BLE service UUID FC5A. It expands the short UUID to the standard Bluetooth UUID form.

Deployment overrides​

Use different settings only when Last Lock provides them for your deployment. An incorrect server prevents Proxy connectivity; an incorrect service UUID prevents lock discovery. Pass a hostname without a scheme, path, or port to initialize(serverAddress). Pass a four-character hexadecimal service UUID to start(serviceUUID) or startLimited(serviceUUID).

A resolved startup promise confirms that the SDK accepted the request. Use discovery, Bluetooth state, and Proxy stream events to track readiness; startup alone does not confirm a lock was found or the attested Proxy stream connected.

Subscribe before starting​

Own the SDK at the application level. Subscribe before starting so you can receive early events, track devices by identifier, and remove subscriptions when their owner is disposed:

import * as LinkMobile from '@last-lock/link-mobile';
import type {DiscoveredDevice} from '@last-lock/link-mobile';

const devices = new Map<string, DiscoveredDevice>();
const subscriptions = [
LinkMobile.onDeviceDiscovered((device) => {
devices.set(device.identifier, device);
console.log('Discovered lock:', device.identifier, device.rssi);
// Publish the updated device list to your application's UI state.
}),
LinkMobile.onBLEStateChanged(({state}) => {
console.log('Bluetooth ready:', state);
}),
LinkMobile.onProxyStreamStateChanged(({state}) => {
console.log('Proxy stream:', state);
}),
LinkMobile.onCredentialExchangeResult((event) => {
console.log('Credential result:', event.identifier, event.result, event.message);
// Finish the pending unlock UI for this identifier here.
}),
];

export async function startLinkMobile() {
await LinkMobile.initialize();
await LinkMobile.start();
}

export function disposeLinkMobileSubscriptions() {
subscriptions.forEach((subscription) => subscription.remove());
}

Call startLinkMobile() while the app is in the foreground, and show any startup error in the UI. The identifier field is the lock's advertised device ID, not its Bluetooth address.

Render discovered locks in React Native​

This component is an alternative to the module-level example. Mount it once near the app root, or move its state into a provider shared by your screens. The button starts the SDK while the app is visible and leaves startup errors on screen for retry. Use one startup example per session.

import React, {useEffect, useRef, useState} from 'react';
import {Button, FlatList, Text, View} from 'react-native';
import * as LinkMobile from '@last-lock/link-mobile';
import type {DiscoveredDevice} from '@last-lock/link-mobile';

export default function NearbyLocks() {
const [devices, setDevices] = useState<DiscoveredDevice[]>([]);
const [radioReady, setRadioReady] = useState<boolean | null>(null);
const [proxyState, setProxyState] = useState('disconnected');
const [status, setStatus] = useState('Ready to start');
const [starting, setStarting] = useState(false);
const [started, setStarted] = useState(false);
const startingRef = useRef(false);
const mounted = useRef(false);

useEffect(() => {
mounted.current = true;
const subscriptions = [
LinkMobile.onDeviceDiscovered((device) => {
setDevices((current) => {
const updated = new Map(current.map((item) => [item.identifier, item]));
updated.set(device.identifier, device);
return Array.from(updated.values());
});
}),
LinkMobile.onBLEStateChanged(({state}) => setRadioReady(state)),
LinkMobile.onProxyStreamStateChanged(({state}) => setProxyState(state)),
];
return () => {
mounted.current = false;
subscriptions.forEach((subscription) => subscription.remove());
};
}, []);

async function begin() {
if (startingRef.current || started) return;
startingRef.current = true;
setStarting(true);
setStatus('Starting Link Mobile…');
try {
await LinkMobile.initialize();
await LinkMobile.start();
if (mounted.current) {
setStarted(true);
setStatus('SDK started; waiting for nearby locks');
}
} catch (error) {
if (mounted.current) {
setStatus(error instanceof Error ? error.message : String(error));
}
} finally {
startingRef.current = false;
if (mounted.current) setStarting(false);
}
}

return (
<View style={{flex: 1, padding: 16}}>
<Button title="Start Link Mobile" disabled={starting || started} onPress={begin} />
<Text>{status}</Text>
<Text>Bluetooth: {radioReady === null ? 'unknown' : radioReady ? 'on' : 'off'}</Text>
<Text>Proxy: {proxyState}</Text>
<FlatList
data={devices}
keyExtractor={(device) => device.identifier}
ListEmptyComponent={<Text>No locks discovered yet.</Text>}
renderItem={({item}) => (
<View style={{paddingVertical: 12}}>
<Text>Lock {item.identifier}</Text>
<Text>RSSI: {item.rssi} dBm · {item.connectionStatus}</Text>
<Text>Signal: {item.signalStrength}</Text>
</View>
)}
/>
</View>
);
}

The SDK can report the same lock again as its advertisement or signal changes. Replace the existing entry by identifier to show the latest observation. Discovery confirms only that the lock was seen; unlock access depends on a valid pass and a successful credential exchange.

The sample retains each lock's last observation. A disconnect does not establish that the lock has left range, and there is no lost-device event. To remove stale entries, record the time of each discovery event and apply an app-defined timeout.

Discovery and connection events​

Discovery describes nearby locks; connection and stream events describe separate parts of the session. Subscribe to the events your UI needs:

SubscriptionUseful fields and meaning
onDeviceDiscoveredidentifier, name, rssi, signalStrength, and connectionStatus, plus advertisement flags such as installMode, dfuMode, lowBattery, and busy.
onDeviceConnected / onDeviceDisconnectedidentifier and name for a native BLE connection change. Update connection state separately from the list of observed locks.
onBLEStateChangedBoolean state indicating whether the Bluetooth radio is on. Use native permission state to distinguish radio-off from access denial.
onProxyStreamStateChangedstate is connected or disconnected for the Link Proxy stream. This is separate from a lock's BLE connection state.
onHeartbeatidentifier and oobTimestamp for a connected lock's last observed server-command timestamp.
onCredentialExchangeResultidentifier, result, and message for the final token-unlock outcome.

Keep each returned subscription and call remove() during cleanup. Avoid subscribing on every render. Pass the advertised identifier to SDK methods. The name field may contain a peripheral address on Android or a Core Bluetooth identifier on iOS; it is not a cross-platform lock ID.

Permissions and lifecycle​

Configure the platform permissions during native setup. At runtime, show the user when Bluetooth is unavailable and offer a way to retry startup.

On iOS, Bluetooth usage descriptions must be present before Core Bluetooth requests access. The native bridge handles scanning and BLE readiness. Combine onBLEStateChanged with native permission state to distinguish a disabled radio from denied access.

On Android, both startup methods request missing permissions through the foreground activity. API 31+ requires BLUETOOTH_SCAN and BLUETOOTH_CONNECT; older supported versions use Bluetooth and location permissions. Regular start also requests background location on API 29–30 and notification permission on API 33+. startLimited skips those additional requests. Check the merged manifest and test grant and denial on supported OS versions. Startup rejects if a requested permission is denied.

Choose start when Android BLE and Proxy work must continue after the app leaves the foreground. It calls Bridge.start and posts a foreground-service notification. Choose startLimited for work needed only while the app is visible. Android may stop that service in the background, so call startLimited whenever the app returns to the foreground. Repeated limited starts reuse a running session. A later start promotes the service and posts its notification; calling startLimited afterward does not remove it. See the Android lifecycle guide.

On iOS, startLimited behaves like start; there is no separate foreground-only mode.

For an Android flow limited to the foreground, use this pattern in place of the regular startup examples. Register discovery listeners first, start while the app is visible, and surface any rejected promise in your UI:

import {AppState, Platform} from 'react-native';
import * as LinkMobile from '@last-lock/link-mobile';

export async function startForegroundLinkMobile() {
await LinkMobile.initialize();
await LinkMobile.startLimited();

return AppState.addEventListener('change', (state) => {
if (Platform.OS === 'android' && state === 'active') {
void LinkMobile.startLimited().catch((error) => {
console.error('Unable to resume Link Mobile:', error);
// Display the error and provide a retry action in your application.
});
}
});
}

// Retain the returned AppState subscription and remove it in the owner's cleanup.
// Do not run both regular and limited startup flows for the same session.

Removing JavaScript subscriptions does not stop the native SDK. The supplied library has no general stop method. Keep startup under an application-level owner; use your chosen native integration route if you need additional lifecycle control.


BLE credential exchange​

Token unlock has two parts: your backend authorizes the user and issues a pass, then the SDK presents that pass to a nearby lock. The SDK generates the credential's public key; your backend signs the pass for it. See the BLE credential exchange example server and the native guides for lock configuration, issuer trust, and pass issuance.

A credential namespace is a stable, app-defined name for an SDK-managed key pair and its stored unlock pass. It is not a secret, lock ID, or server address. Persist one namespace per user and credential, such as a UUID, and reuse it for key generation, pass storage, renewal, and unlock. Creating a new namespace on every launch points to a different credential. See the native Android and iOS persistence examples.

The React Native flow is:

  1. Persist a stable credential namespace in app storage, scoped to the signed-in user. Reuse it when obtaining or renewing that user's pass.
  2. Call fetchTokenUnlockPublicKey(namespace) to obtain the SDK's Base64 public key. Send it to your authenticated backend to request an authorized pass.
  3. Pass the returned Base64 material to storeTokenUnlockPass. The JavaScript method accepts one reader group, using the positional arguments below. Map the backend response to these arguments explicitly.
  4. Call tokenUnlock with an identifier from device discovery and the same namespace. Observe onCredentialExchangeResult for the final result.

For the example server, send the public key as credential_public_key to POST /v1/issue-unlock-pass, with duration in seconds (604800 for seven days). Map its response without decoding the Base64 strings in JavaScript:

storeTokenUnlockPass argumentExample server response field
accessDocumentBase64access_document
readerGroupIdBase64reader_groups[0].reader_group_id
issuerCaPublicKeyBase64reader_groups[0].issuer_ca_public_key
issuerCaCertificateBase64reader_groups[0].issuer_ca_certificate_der

Check that the expected reader group exists before storing the pass. If your backend returns several groups, select the intended group explicitly; the supplied JavaScript method does not accept a list.

import * as LinkMobile from '@last-lock/link-mobile';

export async function credentialPublicKey(namespace: string) {
return LinkMobile.fetchTokenUnlockPublicKey(namespace);
}

// Values below come from your backend's authorized pass response.
export async function saveUnlockPass(
namespace: string,
accessDocumentBase64: string,
readerGroupIdBase64: string,
issuerCaPublicKeyBase64: string,
issuerCaCertificateBase64: string,
) {
const stored = await LinkMobile.storeTokenUnlockPass(
namespace,
accessDocumentBase64,
readerGroupIdBase64,
issuerCaPublicKeyBase64,
issuerCaCertificateBase64,
);
if (!stored) throw new Error('Unable to store the unlock pass');
}

export async function unlockDiscoveredDevice(identifier: string, namespace: string) {
await LinkMobile.tokenUnlock(identifier, namespace);
// This starts the operation. The credential-result event reports its outcome.
}

Enable unlock only after storing the pass, and disable it during an exchange. Handle both immediate promise rejection and the later credential-result event. Renew expired passes through your backend. The JavaScript library does not expose native pass lookup or deletion. If sign-out must delete local credentials, provide that operation through a native integration.

Keep Link API keys and issuer and reader CA private keys on your backend. Authenticate and authorize pass requests there. Send the app only the signed pass and public trust material needed to present it.


Supplied library JavaScript API​

The following methods and events are available from @last-lock/link-mobile in Route 1. Import them individually or as import * as LinkMobile from '@last-lock/link-mobile'. For Routes 2 and 3, use the Swift and Kotlin SDK references.

Every method returns a promise. Handle rejections and, where applicable, the event that reports the final outcome. Methods that target a lock require its advertised identifier from the current discovery session. Base64 arguments are standard Base64 strings. A resolved start or queue request does not confirm that the lock completed the action.

Startup and device control​

Initialize before starting. Use a discovered lock's identifier for device control:

MethodReturnsPurpose
initialize(serverAddress?)Promise<string>Configures the Link hostname without scanning; defaults to plane.lastlock.com. Await this before start.
start(uuid?)Promise<string>Starts or restarts the native BLE / Proxy session; defaults to service UUID FC5A. Android requests missing runtime permissions.
startLimited(uuid?)Promise<string>Defaults to service UUID FC5A. Regular start on iOS; foreground-only Bridge.startLimited on Android. Requests missing Bluetooth permissions without notification/background-location permissions. Call again when Android returns to the foreground.
identify(identifier)Promise<boolean>Queues an identify payload for a discovered lock. true means it was queued, rather than confirmation of a visible response from the lock.
setLockEngagedState(identifier, engaged)Promise<boolean>Queues a lock-engaged (true) or lock-disengaged (false) payload. This control operation is separate from presenting a token-unlock credential.
sendOutOfBandPayload(identifier, payloadBase64)Promise<string>Decodes a standard Base64 out-of-band payload and queues it for a discovered lock. The resolved message confirms it was queued, not that the lock applied it.

Token-unlock credentials​

Use the same namespace throughout key generation, pass storage, and unlock:

MethodReturnsPurpose
fetchTokenUnlockPublicKey(tokenNamespace)Promise<string>Gets the SDK-managed credential's public key as Base64. Send it to your backend for pass issuance; reuse the namespace for the same credential.
storeTokenUnlockPass(tokenNamespace, accessDocumentBase64, readerGroupIdBase64, issuerCaPublicKeyBase64, issuerCaCertificateBase64)Promise<boolean>Persists the signed access document and one reader group's trust material with the namespace's key pair. Every material argument is Base64; the certificate contains DER-encoded bytes. Check the returned value and handle rejection.
tokenUnlock(identifier, tokenNamespace)Promise<string>Starts presentation of the stored pass to a discovered lock. The final result arrives through onCredentialExchangeResult.

See BLE credential exchange for backend response mapping and namespace handling. The JavaScript API has no pass lookup or deletion method.

Firmware updates and install mode​

Beta

Install mode and device firmware update (DFU) are in beta. Behavior and integration requirements may change between releases; support and forward compatibility are not guaranteed.

Your business server must request install-mode and DFU commands through the Link API. The mobile library handles BLE delivery, mode transitions, and progress events. Keep Link API credentials on the server.

The example Python server shows this flow in token_unlock_server.py at POST /v1/install-mode. It calls Link's SendLockMode RPC with a SetDeviceModeRequest for the target lock. Link signs the command and sends it through the Proxy stream. The phone's active Proxy delivers it over BLE.

To update firmware:

  1. Start the mobile SDK and establish Proxy connectivity. Subscribe to onDFUStateChanged, then call checkForDFU(identifier).
  2. If an update is available, call your authenticated business server to request install-mode entry for that device. The Python example accepts {"device_id": 180, "action": "enter", "duration_seconds": 300}; the enter duration must be between 300 and 7200 seconds. Link includes the pending firmware version in the enter command when an image is available.
  3. After the backend accepts the request, call startDFU(identifier) to continue the mobile flow and observe its progress events. A backend 202 response means the command was queued; wait for the lock's mode change and firmware state rather than treating that response as completion.
  4. For a server-requested exit, the Python endpoint accepts {"device_id": 180, "action": "exit"} and asks Link to queue the reboot out of install mode. Coordinate this with the mobile flow and confirm the device leaves install mode.

Authorize each device operation on your business server. The example endpoint shows Link RPC wiring; add your app's authentication and authorization. To enter install mode without updating firmware, use the same backend enter request, then call waitForDeviceMode(identifier, false).

Mobile methods​

These methods check availability, run the mobile update flow, and observe mode transitions. The backend remains responsible for requesting install mode.

MethodReturnsPurpose
checkForDFU(identifier)Promise<DFUCheck>Asks Link whether firmware is available for this lock. status is update_available, up_to_date, or timeout; firmwareVersion and availableVersion describe the versions and are empty on timeout.
startDFU(identifier)Promise<string>Starts the DFU flow after a successful check reports update_available for that lock. Observe onDFUStateChanged for progress and outcome.
cancelDFU()Promise<string>Requests cancellation of the current firmware update. It does not take a device identifier.
waitForDeviceMode(identifier, dfuRequired)Promise<string>Waits up to 20 seconds for the SDK to observe the lock in the requested mode. With dfuRequired=false, accepts install or DFU mode. With dfuRequired=true, waits for DFU mode and then initiates DFU. Rejects on timeout. Does not request or send the install-mode command.
exitDFUInstallMode(identifier)Promise<string>Sends the DFU reboot payload and waits for the lock to leave install mode. Rejects if sending fails or the mode transition times out.

Request install mode through your backend first. Link signs the command, and the running Proxy delivers it over BLE. waitForDeviceMode only observes the resulting mode change.

Subscribe before checking for an update so the app can show progress and the final state:

const firmware = LinkMobile.onDFUStateChanged((state) => {
console.log(state.flowState, state.installProgress, state.postInstallProgress);
});

try {
const check = await LinkMobile.checkForDFU(identifier);
if (check.status === 'update_available') {
// App-defined helper: call your authenticated business server, which
// requests install-mode entry through Link's SendLockMode RPC.
// Reject if the backend does not accept the request.
await requestInstallModeFromBusinessServer(identifier);
await LinkMobile.startDFU(identifier);
// Keep the subscription while the update is active.
} else {
console.log('Firmware check:', check.status);
}
} catch (error) {
console.error('Unable to begin firmware update:', error);
}

// In the owning component/service's cleanup, after it no longer needs updates:
// firmware.remove();

requestInstallModeFromBusinessServer is an app-defined helper. Implement it against your business server using the Python endpoint's request pattern. Handle backend errors before calling startDFU.

DFUState includes flowState, installProgress, postInstallProgress, dfuAvailable, dfuImageLoading, and availableVersion. Progress ranges from 0 to 1. Interpret the native flowState using the matching SDK release; startDFU resolving does not mean installation is complete. DFU events have no device identifier, so track the selected lock in app state and run one update flow at a time.

Event subscriptions and payloads​

Each event method accepts a listener and returns a subscription. Keep that subscription while its owner needs updates, then call remove() during cleanup.

SubscriptionPayload and purpose
onDeviceDiscovered(listener)DiscoveredDevice: the advertised lock identifier, signal, connection status, advertisement flags, and counters listed below. Repeated observations update a device.
onDeviceConnected(listener)DeviceEvent: identifier, name. Reports a BLE connection.
onDeviceDisconnected(listener)DeviceEvent: identifier, name. Reports a BLE disconnection.
onBLEStateChanged(listener)BLEState: boolean state, true when the radio is on.
onCredentialExchangeResult(listener)CredentialResult: identifier, name, result, message. Reports the token-unlock outcome; result names include ACCESS_GRANTED and TIMEOUT.
onDFUStateChanged(listener)DFUState: firmware flow, progress, availability, and image-loading fields described above.
onHeartbeat(listener)Heartbeat: identifier, oobTimestamp, the connected lock's last observed server-command timestamp.
onProxyStreamStateChanged(listener)ProxyStreamState: state, either connected or disconnected, indicating Link Proxy stream liveness.

DiscoveredDevice includes the following fields:

FieldsMeaning
identifier, nameAdvertised device ID and platform peripheral name/identifier. Use identifier for operations.
rssi, connectionStatus, signalStrengthNumeric signal level and native connection/signal classification strings.
installMode, dfuMode, hibernate, serviceModeBoolean operating-mode flags from the advertisement.
lowBattery, criticalBattery, cooldown, emergency, busy, timeSyncRequestBoolean status flags from the advertisement.
overridingModeNative override-mode name.
deviceStateCounter, serverStateCounterNumeric state counters parsed from the advertisement.
oobTimestampLast server-command timestamp observed for the lock, or 0 if none has been observed.

Troubleshooting​

Start with the symptom you see, then check the associated setup or runtime state:

SymptomCheck
npm returns 401/403 or cannot find the packageCheck the @last-lock registry mapping, the classic PAT in ~/.npmrc (or the npm token configured by CI), its read:packages scope, and the exact published version.
Gradle cannot resolve bridge-androidConfigure Maven credentials separately from npm and use the repository / coordinates pinned by the React Native release.
Swift package download failsCheck that the package's exact native SDK tag and release assets are available from the production SDK repository.
LinkMobile native module is not availableUse a native build with the New Architecture enabled and rebuild after installation. On iOS, confirm LinkMobileSpec codegen and bridge sources in the app target. On Android, clean and rebuild if autolinking output is stale.
Expo plugin cannot find the Android repository blockAdd the authenticated Maven repository to your custom native template or managed native project.
No locks discoveredCheck Bluetooth state, permissions, physical hardware, and service UUID FC5A.
Android startup reports permissions deniedCheck every permission requested by the bridge, including notification permission on API 33+ and location permissions on older devices.
Proxy never connectsVerify Internet access, the Link hostname (plane.lastlock.com), and the installed app's exact allowlisted identity and signing certificate.
Unlock starts but no result appears in the UIRegister onCredentialExchangeResult before starting the exchange, retain the subscription, and use the discovered identifier and stored pass namespace.