DoorstepAI Dropoff SDK for React Native
The @doorstepai/dropoff-sdk provides a set of tools to integrate delivery tracking into your React Native application. This SDK enables seamless coordination between your delivery interface and DoorstepAI's backend systems.
📦 Installation
npm install @doorstepai/dropoff-sdk
🚀 Getting Started
1. Import Components
import {
RootDoorstepAI,
DoorstepAI
} from '@doorstepai/dropoff-sdk';
2. Add RootDoorstepAI to Root File
Place the following component in the root of your App.tsx or main entry file:
import { RootDoorstepAI } from "@doorstepai/dropoff-sdk"
const App = () => {
return (
<View>
<RootDoorstepAI
apiKey="YOUR_API_KEY_HERE"
notificationTitle="Some Notification Title"
notificationText="Some Notification Text"
/>
{/** Your App Components */}
)
}
This sets the API key, initializes the SDK, and runs the permission sequence when
the app launches. Every part of it is available a la carte on DoorstepAI — the
component exists so a host that just wants the default flow does not have to write
it.
The notification title/text is passed to Android's foreground service, so you get full control over that text.
Optional props:
| prop | default | what it does |
|---|---|---|
permissions | (every bucket) | The whitelist handed to requestPermissions: exactly these buckets are requested and nothing else. See Permissions. |
requestBackgroundLocation | true | Run the second-phase locationAlways request once foreground location is granted. It has to be a second phase because Android auto-denies a background ask bundled with the foreground one. Turn it off if your own onboarding asks for "Allow all the time". |
requestBluetooth | true | Deprecated — use permissions, which can narrow any bucket rather than just this one. false drops the platform's Bluetooth bucket from the default whitelist. |
<RootDoorstepAI
apiKey="YOUR_API_KEY_HERE"
permissions={['location', 'motion']} // no Bluetooth, no notifications
/>
📲 Example Usage
Use the SDK's functions to track and update delivery events. Below are common examples using buttons.
import { Button } from 'react-native';
import { DoorstepAI } from "@doorstepai/dropoff-sdk"
// Start a delivery using a Google Place ID
<Button title="Start Delivery" onPress={() => {
DoorstepAI.startDeliveryByPlaceID('destination_place_id', 'delivery_id_1');
}} />
// Start a delivery using an address
<Button title="Start Delivery by Address" onPress={() => {
const address = {
streetNumber: '123',
route: 'Main St',
subPremise: 'Apt 4B',
locality: 'New York',
administrativeAreaLevel1: 'NY',
postalCode: '10001'
};
DoorstepAI.startDeliveryByAddress(address, 'delivery_id_1');
}} />
// Send "taking_pod" event
<Button title="Taking POD" onPress={() => {
DoorstepAI.newEvent('taking_pod', 'delivery_id_1');
}} />
// Send "pod_captured" event
<Button title="POD Captured" onPress={() => {
DoorstepAI.newEvent('pod_captured', 'delivery_id_1');
}} />
// End the delivery
<Button title="End Delivery" onPress={() => {
DoorstepAI.stopDelivery('delivery_id_1');
}} />
🔧 SDK API
Every method below is a thin pass-through to the native iOS/Android SDK. Where a
method exists on only one platform it says so, and it rejects on the other
rather than resolving a fabricated value — a helper that quietly resolved
true/[] would read as "granted" / "nothing needed" to a cross-platform host.
Setup
| method | notes |
|---|---|
init(notificationTitle?, notificationText?): Promise<void> | Android only (no-op on iOS). Handled for you by <RootDoorstepAI />. The title/text go on the foreground-service notification. |
setApiKey(apiKey): void | Collection API key. |
setDoorstepGoApiKey(apiKey): void | Doorstep Go is a separate credential and realm; a no-op if you do not use it. |
configureRemoteLogging({ enabled, minLevel?, flushIntervalSeconds?, batchSize?, maxQueueSize? }): void | iOS only (no-op on Android, which drives remote logging from remote config). |
debugStateJson(): Promise<string> | iOS only. Free-form internal-state dump for bug reports — never branch on it. |
retryGnssCallbacks(): Promise<boolean> | Android only. Re-registers raw-GNSS callbacks after the OS silently stops delivering them. |
enableDevMode(apiKey) / validateDevModeAccess() / disableDevMode() | Dev tooling. The last two are Android-only and answer false on iOS. |
Embedded Doorstep Go map
Set the separate Doorstep Go key before mounting the view, then use either the
named export or DoorstepAI.EmbedView:
import { DoorstepAI, DoorstepEmbedView } from '@doorstepai/dropoff-sdk';
DoorstepAI.setDoorstepGoApiKey('YOUR_DOORSTEP_GO_API_KEY');
<DoorstepEmbedView
address="1600 Pennsylvania Avenue NW, Washington, DC"
deliveryID="delivery-123"
inlineHeight={300}
expandedHeight={600}
borderRadius={16}
linkAPI="https://api.core.doorstep.ai"
baseMap="light"
parkingText="95% of drivers parked here"
markerColor="#027FB8"
polygonColor="#3291BC"
parkingColor="#FF847B"
onModeChanged={(mode) => console.log('embed mode', mode)}
onLoad={() => console.log('embed navigation started')}
onSuccess={() => console.log('embed ready')}
onError={(message, error) => console.warn(error.code, error.stage, message)}
/>
DoorstepAI.EmbedView accepts the same props. mode is optional: omit it to let
the native expand/collapse control own the mode, or provide it as a controlled
prop. The component defaults its height to the active mode's inlineHeight or
expandedHeight; an explicit style.height overrides that behavior.
borderRadius (points/dp, default 0) rounds the native map card itself — use it
instead of style.borderRadius, which does not clip the native map. Keep the
view mounted while it is visible, because detaching Android's WebGL-backed map
destroys its graphics context.
For preloading and revealing the same mounted map only after onSuccess, see
embed-preload.example.tsx. It keeps the native
view attached at full size with opacity zero while loading, disables touch and
accessibility while hidden, and handles errors and a bounded preload timeout.
Use show={false} to preload for later, then set it to true to reveal once
ready. Keep its parent mounted and avoid navigation that detaches native screens.
This retains a live view in memory; it does not download a durable offline map.
onLoad fires when navigation starts after the signed link is created;
onLoading remains a compatibility alias. onSuccess fires when the page reports
map readiness, so use it to reveal a preloaded map. onLoad alone does not mean
the map is ready. These callbacks require the updated JavaScript package and a
rebuilt native app on both Android and iOS; an Expo OTA JavaScript update alone
cannot add missing native callbacks.
The optional mapStyleToggle, gpsRecenterToggle, streetViewToggle, and
expandContractToggle props default to true on both platforms. endpoint={{lat, lng}}
forwards destination coordinates, fitGpsOnStart defaults to false, and
readyTimeoutSeconds defaults to 15. Native button analytics travel directly through
the native SDK's WebView bridge to Doorstep Go; React Native does not need to forward them.
onError(message, error) keeps the legacy message as its first argument and supplies a
DoorstepEmbedError as its second: code, stage, message, isRetryable, and optional
httpStatusCode/requestId. Both platforms use the same field names and enum values.
onLoading reports navigation starting; onSuccess reports page readiness.
Deliveries
All four starters take the same trailing StartDeliveryOptions:
type StartDeliveryOptions = {
timeoutSeconds?: number; // hard session cap; OMIT for none
manualForeground?: boolean; // Android only: host owns the FGS
customerId?: string;
driverId?: string;
};
| method | notes |
|---|---|
startDeliveryByAddressString(address, deliveryId, timeoutSeconds?, coordinates?, options?) | Preferred. Pass coordinates when you have them. |
startDeliveryByAddress(address: AddressType, deliveryId, timeoutSeconds?, coordinates?, options?) | Structured address. |
startDeliveryByPlaceID(placeID, deliveryId, timeoutSeconds?, options?) | Google Place ID. |
startDeliveryByLatLng(lat, lng, subUnit, deliveryId, timeoutSeconds?, options?) | Deprecated — use an address method with coordinates. |
startDeliveryByPlusCode(plusCode, deliveryId, timeoutSeconds?, options?) | Deprecated — same. |
markDropoff(deliveryId, 'pod' | 'non_pod'): Promise<void> | Records completion and gates the route-geofence EXIT stop. |
stopDelivery(deliveryId): Promise<void> | |
newEvent(eventName, deliveryId, timestamp?): Promise<...> | timestamp is optional epoch seconds. |
type AddressType = {
streetNumber: string;
route: string;
subPremise: string; // apartment, suite, unit
locality: string; // city
administrativeAreaLevel1: string; // state/province
postalCode: string;
};
timeoutSeconds omitted means NO backstop, which is the native behaviour on
both platforms. (An older version of this wrapper substituted 3600 s here and
force-stopped every delivery at one hour.)
One cross-platform difference the wrapper does not paper over: Android resolves
the server session id ({"id":"<uuid>"}) from a starter and iOS resolves
nothing — native returns Void there. Treat a resolved promise as the success
signal, not a truthy return value. On iOS, listen for the SDK's
sessionServerIdAssignedNotification natively if you need the id.
Route geofencing
The SDK fences each stop and auto-starts a delivery on qualified ENTER. The server owns
radius and EXIT policy (misc.geofence.waitForDropoff): true/missing requires markDropoff
before EXIT stops the session; false permits unmarked EXIT and keeps the stop armed.
Whole-route telemetry is also server controlled. Background routes require location
authorization and the native SDK's supported foreground/background execution setup;
Always authorization provides the broadest recovery support.
type DeliveryStop = {
deliveryId: string;
address: string;
latitude: number;
longitude: number;
routeStopId?: string; // stable identity; defaults to deliveryId
radiusMeters?: number; // deprecated, ignored; radius is server controlled
customerId?: string;
driverId?: string;
};
type RouteOptions = {
defaultRadiusMeters?: number; // deprecated, ignored
timeoutSeconds?: number; // per auto-started session
autoStopAfterDropoffSeconds?: number;
manualForeground?: boolean; // Android only; ignored on iOS
maxAgeSeconds?: number; // Android only
clientRouteId?: string;
driverId?: string;
metadata?: Record<string, string>;
};
// RouteGeofenceOptions remains a deprecated alias for source compatibility.
For Android routes using a host-owned location foreground service, start that service while the app is visible, then await readiness before starting the route:
await Location.startLocationUpdatesAsync(LOCATION_TASK, locationOptions);
await DoorstepAI.waitForHostForegroundService({
timeoutMs: 10_000,
serviceClassName: 'expo.modules.location.services.LocationTaskService',
});
await DoorstepAI.startRouteGeofencing(stops, { manualForeground: true });
waitForHostForegroundService(options?): Promise<void> is Android-only and
rejects on iOS. It checks immediately, then every 100 ms using Android's own-app
running-service query. timeoutMs defaults to 10000 and accepts integer values
from 0 to 120000; zero performs one immediate check. Omit serviceClassName to
accept any foreground service in your app except Doorstep's tracking service.
For Expo, match its service class as above so unrelated services cannot satisfy
the wait. Rejections have native codes HOST_FGS_TIMEOUT,
HOST_FGS_CHECK_FAILED, or HOST_FGS_CANCELLED (module teardown).
The query is deprecated but still supports inspecting the caller's own services.
Success is a snapshot of foreground state, not proof of location-service type,
permissions, fresh fixes, or future uptime. Keep the host location service alive
for the entire route. This method does not start a service, initialize tracking,
or alter manualForeground. Updating JavaScript alone is insufficient: rebuild
the native Android app after updating the SDK.
| method | notes |
|---|---|
startRouteGeofencing(stops, options?): Promise<void> | Rejects if no stop has a deliveryId + coordinates. |
updateRouteStops(stops): Promise<void> | Replaces the list in place. Removing a stop that has an active session stops it immediately (reason removed_from_route) — not EXIT-driven, not dropoff-gated. Does not extend the route lease. |
stopRouteGeofencing(): Promise<void> | Teardown is handed to the tracking service / region manager, so getMonitoredStops() can still show the old list for a moment. |
resumeRouteGeofencingIfNeeded(): Promise<void> | Call this on every launch. A per-launch host obligation: without it Android silently loses route monitoring across a process kill. |
getMonitoredStops(): Promise<DeliveryStop[]> | The SDK's intent registry, not the OS's registered-region set. |
isRouteGeofencingActive(): Promise<boolean> | Durable route intent, independent of an active delivery. |
getActiveRouteId(): Promise<string | null> | Canonical SDK route UUID on either platform. |
getActiveRouteRevision(): Promise<number | null> | Current manifest revision, or null without a route. |
addMetadata(deliveryId, metadata): Promise<void> | Attach JSON metadata before or after a delivery session starts. |
addGeofenceSessionListener(cb): EmitterSubscription | undefined | { deliveryId, type: 'STARTED' | 'STOPPED', reason }. Call .remove() to unsubscribe. |
const sub = DoorstepAI.addGeofenceSessionListener(({ deliveryId, type, reason }) => {
console.log(type, deliveryId, reason);
});
await DoorstepAI.startRouteGeofencing(stops, { clientRouteId: 'route-123', driverId: 'driver-123' });
// ...later
sub?.remove();
Permissions
checkPermissions, checkPermissionsJson, requestPermissions,
allPermissionBuckets, notePermissionRequestResult, and the Android-only query
helpers. See Permissions below — that section is the reference.
✅ Best Practices
- Always wrap
DoorstepAIAPI calls intry/catchor use.catch()for better error handling when applicable. - Use the
RootDoorstepAIcomponent only once at the root of your app. - Ensure your API key is securely stored and never hardcoded in production.
- Always provide a unique
deliveryIdfor each delivery session. - Keep track of the
deliveryIdthroughout the delivery lifecycle as it's required for all operations.
🧪 Debugging
If something isn't working as expected:
- Check console logs for
DoorstepAI initializedor error messages. - Verify that the API key is valid and accepted by the SDK.
- Ensure your permissions (e.g., location) are correctly set up on the device.
- Verify that you're using the correct
deliveryIdfor each operation.
🔐 Permissions
To use the @doorstepai/dropoff-sdk effectively, you must include the appropriate permissions in your mobile app's configuration files.
📱 iOS
In your Info.plist, include the following usage descriptions:
<!-- Location Usage Descriptions -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app requires access to your location for delivery tracking.</string>
<key>NSLocationAlwaysAndWhenInUseUsage</key>
<string>This app requires access to your location to provide continuous tracking even in the background.</string>
<!-- Optional: Motion & Sensor Data (if applicable) -->
<key>NSMotionUsageDescription</key>
<string>This app uses motion data to enhance delivery tracking accuracy.</string>
<!-- Required if you let Bluetooth default to on (see the permission API below).
Without this key the SDK skips the Bluetooth request with a warning. -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to improve delivery location accuracy.</string>
<!-- Background Location Mode -->
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
🤖 Android
The SDK's own manifest already declares every permission it needs, so manifest
merging supplies them to your app. Declare these only if you want them visible in
your own AndroidManifest.xml:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.sensor.accelerometer" />
<uses-feature android:name="android.hardware.sensor.gyroscope" />
<uses-feature android:name="android.hardware.sensor.barometer" />
<uses-feature android:name="android.hardware.sensor.compass" />
<uses-feature android:name="android.hardware.sensor.proximity" />
Requesting them at runtime
<RootDoorstepAI /> does this for you. Use the API directly if you drive
permissions from your own onboarding flow — every method delegates to the native
SDK's own whitelist permission API, so the buckets, the states, the prompt order
and the "what will I decline to ask, and why" planning stay whatever the SDK says
they are.
One requester, both platforms. No argument requests everything; an argument is a
whitelist and nothing outside it is ever asked for. Do not branch on
Platform.OS to ask for permissions — the branch is inside the wrapper, on
DoorstepAI.requestPermissions(_ permissions:…) (iOS) and
DoorstepAI.requestPermissions(activity, permissions) (Android).
await DoorstepAI.requestPermissions(); // every bucket
await DoorstepAI.requestPermissions(['locationWhenInUse']); // only this one
await DoorstepAI.requestPermissions({ // object form
permissions: ['location', 'motion'],
onlyIfNotDetermined: true, // iOS
timeoutSeconds: 60,
});
Buckets
A bucket is one user-visible OS decision. They are deliberately OS-specific, because the platforms are — only the two location buckets exist on both:
| bucket | iOS | Android |
|---|---|---|
locationWhenInUse | requestWhenInUseAuthorization | ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION |
locationAlways | requestAlwaysAuthorization | ACCESS_BACKGROUND_LOCATION |
motionFitness | Motion & Fitness | — |
activityRecognition | — | ACTIVITY_RECOGNITION |
bluetooth | CBCentralManager | — |
bluetoothScan | — | BLUETOOTH_SCAN (API 31+) |
nearbyWifiDevices | — | NEARBY_WIFI_DEVICES (API 33+) |
notifications | — | POST_NOTIFICATIONS (API 33+) |
You can pass either platform's name on either platform: the wrapper translates
the counterpart (motionFitness ⇄ activityRecognition, bluetooth ⇄
bluetoothScan), so one whitelist works on both without a Platform.OS branch. A
name with no counterpart at all (notifications on iOS) comes back in
unsupported rather than being dropped in silence. The coarse aliases 'location'
(→ both location buckets), 'motion' / 'activity' and 'bluetooth' are still
accepted — they are what the published 2.x API took.
DoorstepAI.allPermissionBuckets() returns this platform's list if you want to
build a permission screen without hardcoding it.
States
Eight states, the same names on both platforms, so host code branches once:
| state | meaning |
|---|---|
granted | held right now |
notDetermined | never asked; a request will show a dialog |
whenInUseOnly | foreground location held, background not. Only ever reported for locationAlways |
denied | refused, another prompt is still possible (Android only) |
permanentlyDenied | refused, no further prompt possible — deep-link to Settings. Every iOS refusal lands here |
restricted | blocked by device policy / MDM / Screen Time |
unavailable | the OS version or the hardware does not have it. Not a failure |
notDeclared | your build is missing the declaration (<uses-permission> / NS…UsageDescription). Not a user refusal — fixable by you |
whenInUseOnly is the load-bearing one: never treat it as granted. Background
collection and route geofencing do not work in it.
// Read the current state. Pure observation — never shows a dialog.
const { statuses } = await DoorstepAI.checkPermissions();
if (statuses.locationAlways?.state === 'whenInUseOnly') {
// foreground-only: no background collection, no route geofencing
}
if (statuses.locationWhenInUse?.state === 'permanentlyDenied') {
// a request would be answered instantly with no dialog — send them to Settings
}
What a request answers with
requestPermissions resolves only after the user has answered, with the settled
state — a status read while a prompt is still up would be a lie. Everything native
declined to ask comes back explained rather than silently not happening:
const result = await DoorstepAI.requestPermissions();
result.requested; // buckets a dialog was actually raised for
result.alreadyDetermined; // already answered, so left alone
result.unavailable; // absent on this device / missing from this build
result.deferred; // deliberately skipped, with the reason
result.statuses; // state of every whitelisted bucket, after settling
result.permissions; // legacy { location, motion, bluetooth } view
result.unsupported; // whitelist entries this platform has no bucket for
Expect locationAlways in deferred on a full-whitelist call. It is only ever
asked alone and only after foreground location is granted — Android auto-denies
a bundled background ask, and iOS shows the When-In-Use prompt first and offers the
Always upgrade later on its own schedule. So the standard two-phase flow is:
const first = await DoorstepAI.requestPermissions();
if (first.statuses.locationWhenInUse?.state === 'granted') {
await DoorstepAI.requestPermissions(['locationAlways']);
}
Device-measured, Moto G stylus 5G / Android 14: a full-whitelist call raises the
five askable dialogs in one batch and returns
deferred: { locationAlways: 'ACCESS_BACKGROUND_LOCATION cannot be requested before foreground location is granted…' }. iPhone 16 Pro / iOS 26.6: it raises
locationAlways, motionFitness and bluetooth one at a time and returns
deferred: { locationWhenInUse: 'covered by the locationAlways prompt in this call…' }.
A second call made while one is still in flight does not interleave prompt
sequences: iOS reports every bucket as deferred, Android rejects with
E_PERMISSION_REQUEST_IN_FLIGHT. If the user never answers, the wait gives up after
timeoutSeconds (default 120 s) and resolves with the state as it stands.
iOS guidance from the SDK: call it once, from a user-initiated flow, not from app launch.
Android: forward your grant results (optional, worth doing)
Android reports identical signals for "permanently denied" and "never asked", and
a cancelled dialog leaves the same trace as a denial. The SDK refuses to guess,
so without a real answer a refusal reports as the re-askable denied. Forward the
result and you get permanentlyDenied — and the JS promise settles on the answer
itself instead of on a window-focus heuristic:
// MainActivity.kt
import com.doorstepai.dropoffsdk.DoorstepAIModule
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
DoorstepAIModule.notePermissionRequestResult(this, permissions, grantResults)
}
There is a JS entry point too (DoorstepAI.notePermissionRequestResult(permissions, grantResults)) if the values reach you there instead.
Legacy three-value view
statuses is the real answer, but every result also carries the
{ location, motion, bluetooth } × granted | denied | prompt map the published
2.x API returned, so existing hosts keep working. It is collapsed from statuses:
whenInUseOnly and unavailable → granted, notDetermined → prompt,
everything else (including notDeclared) → denied.
Two things it cannot tell you, which is why it is deprecated: While-In-Use vs Always, and a host-build mistake vs a user refusal.
motion stays unconditionally granted on Android, exactly as in 2.x. That is the
honest answer to what the key asks — Android's IMU streams (accel, gyro, mag,
rotation vectors, barometer) need no runtime grant, so motion collection is
always possible there. activityRecognition adds only the step-counter streams,
and the SDK never blocks a session on it; read statuses.activityRecognition for
its real state. On iOS motion does track Motion & Fitness, which genuinely gates
CMMotionActivityManager, CMSensorRecorder and absolute altitude.
Android-only query helpers
These reject on iOS rather than resolving true/[], which a cross-platform host
would read as "granted" / "nothing needed".
await DoorstepAI.hasRequiredPermissions(); // -> boolean
await DoorstepAI.hasBackgroundLocationPermission(); // -> boolean
await DoorstepAI.requestBackgroundLocationPermission();
// Want the grant results in JS? Request the SDK's own flat list with RN's API.
const required = await DoorstepAI.getRequiredPermissions();
await PermissionsAndroid.requestMultiple(required);
requestPermissions(['locationAlways']) is the cross-platform way to express the
same intent as requestBackgroundLocationPermission(), and it reports back why if
the OS would have refused to show the prompt.