@safetly/driver-sdk
v0.1.0
Published
Embedded Safetly driver profile for third-party logistics apps
Readme
@safetly/driver-sdk
Embedded Safetly driver profile for third-party logistics apps.
Drop this package into your existing driver app and your drivers get a Safetly identity — a rotating QR code the relay operator scans at the counter, plus read-only views of their relay points, packages, operations and receipts. There is no second app for the driver to install, no Safetly login, and no router integration: you mount one provider and render the screens you want, wherever your navigation already puts them.
This document is written for the engineers integrating the SDK. It is the only Safetly documentation you need in order to ship.
1. Install
npm install @safetly/driver-sdk
npx expo install expo-secure-store expo-crypto react-native-svg
npm install react-native-qrcode-svgThe five peer dependencies — all of them are required
| Peer | Range | Why |
|---|---|---|
| react | >=18.0.0 | The SDK ships React components. |
| react-native | >=0.81.0 | ditto. |
| expo-secure-store | >=15.0.0 | iOS Keychain / Android Keystore. Holds the device's Ed25519 private key and the driver's TOTP secret. |
| expo-crypto | >=15.0.0 | The OS CSPRNG. |
| react-native-qrcode-svg | >=6.0.0 | Renders the QR matrix on QRIdentityScreen. It pulls react-native-svg, which you install alongside it. |
expo-crypto is the one that gets missed, because nothing in the SDK's public
API mentions it. React Native has no globalThis.crypto, so key generation
falls through to expo-crypto's native CSPRNG. Omitting it does not fail at
install time or at build time — it fails at runtime, on the driver's first
launch, inside enrollment, with:
Cannot read property 'getRandomValues' of undefinedIf you see that, you are missing expo-crypto. Nothing else produces it.
Installing the SDK by path, inside your own monorepo
If you consume the package from a local path ("@safetly/driver-sdk": "file:../sdk")
rather than from a registry, Metro needs to be told not to walk up out of your
app when it resolves a bare import. Without it, a module imported from the SDK's
real path resolves against the SDK's own node_modules first, you end up with
two copies of React Native, and the app dies before the first render with
[runtime not ready]: TypeError: property is not writable.
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const projectRoot = __dirname;
const sdkRoot = path.resolve(projectRoot, "../sdk"); // wherever the SDK lives
const config = getDefaultConfig(projectRoot);
config.watchFolders = [sdkRoot];
config.resolver.disableHierarchicalLookup = true; // <- the load-bearing line
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(sdkRoot, "node_modules"),
];
config.resolver.extraNodeModules = {
react: path.resolve(projectRoot, "node_modules/react"),
"react-native": path.resolve(projectRoot, "node_modules/react-native"),
};
module.exports = config;None of this is needed for a plain registry or tarball install, where the SDK
lands under your own node_modules like any other package.
Do not smoke-test this package with plain Node
require("@safetly/driver-sdk") (or any other attempt to load it outside
Metro) will throw, and it is not a sign that the package is broken. The
peer dependencies expo-secure-store and expo-crypto resolve to
expo-modules-core's raw TypeScript source — an Expo/Metro convention that
relies on the Babel transform every real consumer (Expo Go, a dev client, a
release build) applies. Plain Node has no such transform and dies on the
first TypeScript token, before any of our code runs.
Verify an install by importing the package from your app, under Metro. The SDK's own unit tests work around this with vitest aliases that stub the two native modules; those stubs are test-only config and are never part of the published build.
2. The publishable key
Safetly issues your company one key, shaped sk_live_..., out of band. Put it
in your app's build config and ship it in the binary.
Where the key comes from, and the one chance you get to copy it
You do not mint this key yourself. A Safetly platform admin issues it from the back office's SDK key management surface: they pick your logistics company, give the key a label and create it. The label is what a later revocation is traced by, so ask for one that names the build channel.
The key is shown exactly once. Only a hash of it is stored, so neither the back office nor the database can show it again — if it is lost, the only remedy is issuing a new key and shipping a new build. Copy it straight into your secret store / build config the moment it appears.
Ask for one key per app build channel you ship (e.g. staging and production), labelled so a leak can be traced and deactivated without touching the other.
It is publishable by design:
- It authorizes enrollment only —
POST /api/v1/sdk/enroll, nothing else. - It grants no read access. It cannot fetch a driver, a package, an operation or a receipt. Every one of those calls is authorized by the device's own key, which is generated on the device and never leaves it.
- It is scoped to your logistics company. A device enrolled with your key can
only ever resolve to a driver affiliated with your company — and that
narrowing follows the device for its whole life, not just at enrollment.
Every read the SDK makes is restricted, in the backend's SQL, to the one
company the device enrolled with, so relay points, packages, operations
and receipts belonging to another carrier are not merely hidden: they are
not in the result set, and neither are they in the counts or pagination
totals. A driver affiliated with two carriers therefore sees, through your
build, only what is yours — even though the Safetly app itself shows them
the union. Naming a different company explicitly (the
logisticsCompanyIdprop, §4) is refused with403 COMPANY_NOT_IN_SCOPE, never silently rewritten. - Enrollment is rate-limited per IP and every call is audited server-side with the key that made it, so a leaked key is detectable and revocable without touching any already-enrolled device.
Treat it like a Firebase or Stripe publishable key: not a secret, but also not something to publish in a public repo. If it is ever misused, tell Safetly and the key is deactivated; enrolled devices keep working.
3. Mounting the provider
Mount SafetlyDriverProvider once, above any Safetly screen, and only
after your own app has authenticated the driver. The SDK does not
authenticate anyone — it trusts the identity you hand it.
Configuration: one JSON file per environment
Safetly supplies the two values out of band — the API origin for each
environment you ship, and the publishable key that goes with it (§2). They are
never baked into this package, and this package contains no Safetly URL: every
endpoint the SDK calls is derived from the apiUrl you pass.
Keep them in a config file you own, one per environment, checked into your own repo:
config/safetly.staging.json:
{
"apiUrl": "https://staging-api.example.com",
"apiKey": "sk_live_replace_with_your_staging_key"
}config/safetly.production.json:
{
"apiUrl": "https://api.example.com",
"apiKey": "sk_live_replace_with_your_production_key"
}Replace both example.com hosts and both keys with the values Safetly gave
you. The two files must not share a key: ask for one key per build channel
(§2), so a leak in one can be deactivated without touching the other.
Import the file for the build you are producing and hand it straight to the provider:
import { SafetlyDriverProvider, QRIdentityScreen } from "@safetly/driver-sdk";
// One import per build channel. A JSON import is a module-level constant,
// which is exactly what the provider wants — see the warning below.
import safetlyConfig from "../config/safetly.production.json";
export function SafetlyTab() {
const driver = useMyAppsSignedInDriver();
return (
<SafetlyDriverProvider
config={safetlyConfig}
driverExternalId={driver.id}
phone={driver.phone}
name={driver.fullName}
>
<QRIdentityScreen />
</SafetlyDriverProvider>
);
}Pick the file however your build already picks environments — a Metro/Babel
alias, an app.config.ts branch on the EAS profile, or two entry files. If you
would rather read the values from process.env.EXPO_PUBLIC_*, that works too;
just build the object once, at module scope, never inline.
If that
import ... from "*.json"does not compile, yourtsconfig.jsonneeds"resolveJsonModule": true(Expo'sexpo/tsconfig.basesets it, but a hand-rolled or older config may not). Metro bundles JSON imports regardless; this is a TypeScript setting, not a bundler one. If you would rather not enable it, aconfig/safetly.production.tsexporting the same object works identically — the provider only cares that the object is a module-level constant.
Pass
configby reference. The provider takesconfigas an effect dependency by identity. A fresh object literal on every render (config={{ apiUrl, apiKey }}) re-runs the enroll/restore effect on every render, flickersstatusback to"loading", and re-reads SecureStore for nothing. A JSON import or a module-level constant is stable; anything computed inside a component must go throughuseMemo.
Both fields are validated at initialisation
The provider checks config before it touches the network, and a bad value
fails immediately with a named error rather than surfacing later as a fetch
failure you cannot tell from an outage:
apiUrlmust be present, a non-empty string, and anhttp://orhttps://origin with a host. Surrounding whitespace and trailing slashes are fine — the SDK strips both, and strips them in the one place every URL it builds goes through, so what is validated is exactly what is fetched. A barehttps://with no host, a query string, a fragment, or whitespace inside the host are each rejected: none of them survives having/api/v1/...appended.apiKeymust be present, a non-empty string, and start withsk_live_. Surrounding whitespace is stripped here too, and the header the SDK sends carries the stripped value.
That is the whole of it, deliberately. Nothing past the prefix is checked client-side — not the key's length, its shape, or whether it is live, revoked or scoped to your company. Those are the server's to judge, and a client-side rule that guessed wrong would refuse a key the server would have accepted.
A path prefix (https://gw.example.com/safetly) is accepted, and works only
behind a gateway that strips the prefix before the request reaches Safetly.
The Safetly API serves /api/v1/... at the root of its origin, and the request
signature covers that path and not your prefix. Behind a path-preserving proxy
the prefixed path reaches Safetly unchanged and matches no route, so every
call — enrollment included — fails with 404. If you are not sure which kind
of proxy you have, point apiUrl at the origin Safetly gave you and avoid the
question.
A failure arrives through the provider's normal error surface: status becomes
"error", error is a SafetlyConfigError carrying a field of "apiUrl" or
"apiKey", and unrecoverable is true — retrying cannot fix a wrong value in
your own build. Both SafetlyConfigError and assertValidConfig are exported,
so you can also assert your config at app startup, before any Safetly screen
mounts.
Props
| Prop | Type | Notes |
|---|---|---|
| config | { apiUrl: string; apiKey: string } | apiUrl is the API origin with no trailing slash and no /api/v1. Memoize it. |
| driverExternalId | string | Your identifier for this driver. Stable for the life of the driver. |
| phone | string | The driver's phone. See the contract below — this one is not free-form. |
| name | string \| null | Optional. Only used to fill in a missing name on the Safetly side; it never overwrites one Safetly already has. |
| theme | Partial<SafetlyTheme> | Optional. Merged over the defaults so the screens carry your branding. |
| children | ReactNode | Your Safetly screens. |
The driverExternalId / phone contract
These two fields are the whole identity contract. Get them wrong and the driver either cannot be identified at the counter or is identified as someone else.
driverExternalId is your own primary key for the driver — an employee
number, a UUID, whatever you already use. Safetly never interprets it; it uses
it to answer one question: is this the same driver as the last enrollment on
this device? It must be:
- Stable for the life of the driver. If it changes, the next launch re-enrolls and supersedes the old device row.
- Unique within your company. Two different drivers sharing one
driverExternalIdwill supersede each other's devices forever.
phone must be the exact same number Safetly already holds for that driver.
This is not a display field. The relay operator identifies a driver by phone
plus a 6-digit code — the QR payload is literally
{ "type": "driver_id", "phone": "...", "totp_code": "..." } — and the backend
resolves the driver by matching on that phone. Send the same string Safetly has:
E.164, including the country code, no spaces, no parentheses, no local 0
prefix.
Correct: +212600112233
Wrong: 0600112233 (no country code — matches nothing)
Wrong: +212 600 11 22 33 (spaces — matches nothing)If the number does not match an existing Safetly driver, enrollment does not fail — it creates a new driver under your company with that phone. The driver then exists twice in Safetly with two different numbers, holds a valid QR code, and identifies as the wrong record at the counter. Nothing surfaces an error; it just quietly goes wrong. Reconcile your phone formatting against what Safetly holds before you roll out, not after.
Changing a driver's phone later is a Safetly-side change; tell us, don't re-enroll with a new number.
4. Screens
Every screen must be rendered inside SafetlyDriverProvider. Screens handle
their own loading, empty and error states, and all navigation is callback
props — the SDK requires no router, no navigation library, and no deep-link
setup. Wire the callbacks into whatever navigation you already have.
import {
QRIdentityScreen,
RelayPointsScreen,
RelayPointDetailScreen,
PackagesScreen,
OperationsScreen,
ReceiptScreen,
} from "@safetly/driver-sdk";QRIdentityScreen
The reason the SDK exists. Shows the rotating QR plus the 6-digit code and a countdown. Works offline (see §6).
| Prop | Type | Default |
|---|---|---|
| size | number | 260 — QR edge length in px. |
RelayPointsScreen
The driver's relay points. Pass coordinates to switch to the nearby endpoint,
sorted by distance.
| Prop | Type | Default |
|---|---|---|
| onSelectRelayPoint | (relayPoint: { id: string; name: string; ... }) => void | required |
| coordinates | { lat: number; lng: number } | optional — when supplied, the nearby endpoint is used instead of the full list |
| emptyLabel | string | "No relay points assigned to you yet." |
| errorLabel | string | "Could not load relay points" |
RelayPointDetailScreen
| Prop | Type | Default |
|---|---|---|
| relayPointId | string | required |
| onViewPackages | (relayPointId: string) => void | required |
| viewPackagesLabel | string | "View packages" |
| errorLabel | string | "Could not load this relay point" |
PackagesScreen
| Prop | Type | Default |
|---|---|---|
| relayPointId | string | required |
| logisticsCompanyId | string | optional — must be the enrolled company (see below) |
| status | string | optional filter |
| emptyLabel | string | "No packages at this relay point." |
| errorLabel | string | "Could not load packages" |
logisticsCompanyIdis no longer a free filter. A device is enrolled for exactly one logistics company — the one whose publishable key it enrolled with — and every read the SDK makes is narrowed to that company in the backend's SQL whether you pass the prop or not. Passing it is therefore redundant at best; passing any other company id is refused with403 COMPANY_NOT_IN_SCOPErather than silently rewritten, so you get an error state, not a list describing a company you did not ask about. If your host app has a company picker across several carriers, do not wire it to this prop: omit it, or pass the id of the company this build enrolled with.
OperationsScreen
| Prop | Type | Default |
|---|---|---|
| onSelectOperation | (operationId: string) => void | required |
| emptyLabel | string | "No operations yet." |
| errorLabel | string | "Could not load operations" |
ReceiptScreen
| Prop | Type | Default |
|---|---|---|
| operationId | string | required |
| notAvailableLabel | string | "Receipt not available yet" |
| errorLabel | string | "Could not load this receipt" |
Wiring them together
function SafetlyStack() {
const [screen, setScreen] = React.useState({ name: "relayPoints" });
switch (screen.name) {
case "relayPoints":
return (
<RelayPointsScreen
onSelectRelayPoint={(rp) => setScreen({ name: "detail", id: rp.id })}
/>
);
case "detail":
return (
<RelayPointDetailScreen
relayPointId={screen.id}
onViewPackages={(id) => setScreen({ name: "packages", id })}
/>
);
case "packages":
return <PackagesScreen relayPointId={screen.id} />;
}
}Swap the useState for your own navigator's navigate() — the shape of the
callbacks does not change.
Theming
<SafetlyDriverProvider theme={{ primary: "#0055FF", radius: 8 }} ...>SafetlyTheme is { background, surface, text, textMuted, primary, border,
danger, radius }. Your partial is merged over defaultTheme; no screen
hard-codes a colour.
Going beyond the screens
useSafetlyDriver() gives you the raw state and a typed client if you want to
build your own UI:
const { status, state, client, error, retry } = useSafetlyDriver();
// status: "loading" | "ready" | "error"
// client: getProfile, getRelayPoints, getNearbyRelayPoints,
// getRelayPointDetail, getRelayPointPackages, getOperations,
// getOperationReceipt (all read-only)5. superseded_previous_device — what your support team needs to know
A Safetly driver identity lives on one device at a time. When a device
enrolls for a driverExternalId that already has an active device, the new
device wins and the previous one is immediately and permanently unable to
identify at a relay: its stored TOTP secret is rotated away, so its QR code and
its 6-digit code are simply rejected at the counter from that moment on.
The enrollment response carries superseded_previous_device: true when that
happened. Safetly logs it at warning level and writes an append-only
superseded audit event with the API key and IP that caused it.
Note: the SDK does not currently surface this flag through
useSafetlyDriver() — it is consumed by the backend's audit trail. If you want
it in your own app, ask us and we will expose it on the context.
Why it matters. Supersede is normal and expected when a driver replaces their phone, reinstalls your app, or clears app data. It is not normal otherwise. A driver whose QR suddenly stops working, who has not changed phones, means one of two things and both need action:
- Your
driverExternalIdis not unique — two drivers are sharing one, and they will keep knocking each other offline forever. This is a data bug on your side. - Someone else enrolled as that driver — i.e. your publishable key plus that driver's identifiers were used on a device that is not theirs. That is the impersonation signal the audit trail exists to catch.
So the support script is: "my QR stopped working" → ask whether they changed
or reinstalled the phone. If not, escalate to Safetly with the driver's
driverExternalId and their phone number. The phone is the one that always
works: supersede and its audit events are recorded against the Safetly driver
the enrollment resolved to — which is resolved from the phone — as well as
against the driverExternalId. An impersonator can choose any
driverExternalId they like, and case 2 above is exactly the case where they
did; the phone is what they cannot change without ceasing to impersonate that
driver.
6. The offline guarantee
Generating the identity code never touches the network. The TOTP secret is
handed to the device once, at enrollment, and stored in the Keychain/Keystore.
QRIdentityScreen derives the code from that secret and the device clock. A
driver at a relay point with no signal can still be identified.
The one requirement: the device must enroll online exactly once. Until the first successful enrollment there is no secret on the device and there is nothing to show. Make sure the driver's first launch of your app happens with connectivity — at your depot, not at the counter.
Two details worth knowing:
- Clock drift is handled. The verifier accepts a ±4 minute window, and the
SDK additionally records the server's clock offset from the
Dateheader of any successful request and applies it to code generation. A device whose clock is badly wrong still produces accepted codes after it has made one online request. - Everything other than the identity code — relay points, packages, operations, receipts — is live data and does require the network.
7. Error codes to handle
Every code below comes from the server. A wrong apiUrl or apiKey in your own
build never gets this far — it is a SafetlyConfigError, thrown at
initialisation before the first request (§3).
The SDK throws SdkApiError with statusCode and errorCode:
import { SdkApiError } from "@safetly/driver-sdk";
try {
await client.getProfile();
} catch (err) {
if (err instanceof SdkApiError) {
switch (err.errorCode) {
case "INVALID_API_KEY": /* build/config problem — see below */ break;
case "NO_DRIVER_ID": /* not a Safetly driver */ break;
case "AFFILIATION_REVOKED": /* no longer with this company */ break;
default: /* show a generic retry */ break;
}
}
}| Code | HTTP | When | What to do |
|---|---|---|---|
| INVALID_API_KEY | 401 | Enrollment with an unknown or deactivated publishable key. | A build/configuration problem, never a driver problem. Do not retry. Show "Safetly is unavailable" and alert your own ops — every driver on this build is affected. |
| MISSING_API_KEY | 401 | No x-safetly-api-key header reached the backend. | Same class as above: config.apiKey is empty or undefined in this build. |
| NO_DRIVER_ID | 403 | The authenticated identity has no Safetly driver record. | Usually a phone that does not match what Safetly holds (§3). Surface "Your Safetly profile is not set up" and do not retry in a loop. |
| AFFILIATION_REVOKED | 403 | The driver's affiliation with the logistics company has been revoked — returned both from the driver endpoints and from /sdk/enroll. | Terminal, and deliberately so: enrollment will not resurrect an affiliation an operator revoked (it used to, which made revocation last only until the driver's next app launch). Hide the Safetly section; reset() and re-enrolling will not help. Only a re-grant by the logistics company clears it. useSafetlyDriver().unrecoverable is true in this state. |
| DEVICE_INACTIVE | 401 | The device row is no longer active — it was superseded by another enrollment, or revoked. | This is what a superseded device sees. Treat it as "this device is no longer the driver's device": the SDK already refreshes and retries for you, so seeing this means it is terminal: useSafetlyDriver().unrecoverable is true. Clear the Safetly section and, if the driver is genuinely on this phone, call reset(). See §8. |
| COMPANY_NOT_IN_SCOPE | 403 | The request named a logistics_company_id that is not the one this device enrolled with — in practice, a logisticsCompanyId prop pointing at another carrier. | An integration bug, not a driver problem, and it will not clear by retrying. Drop the prop (the backend already narrows every read to the enrolled company) or pass the company this build's publishable key belongs to. The request is refused rather than rewritten on purpose: a 200 describing the wrong company is harder to debug than an error. |
| SDK_COMPANY_UNRESOLVED | 403 | The device authenticated, but the backend could not resolve which company it belongs to. | Not something a host app can fix, and the backend fails closed here rather than falling back to the driver's other affiliations. Do not retry in a loop; show a generic unavailable state and report it to Safetly with the driver's phone and the approximate time. |
| VALIDATION_ERROR | 422 | Malformed enrollment payload (e.g. a phone shorter than 6 characters). | A bug in your integration; the message names the field. |
| SIGNATURE_EXPIRED | 401 | The signed timestamp was outside the ±5 minute window — the phone's clock is off. | Handled for you (§8): the SDK re-syncs against the server's clock and retries once. Never terminal, because re-enrolling would fail the same way. |
| SIGNATURE_INVALID | 401 | Two unrelated conditions under one code. Either the signature did not verify against the key Safetly holds for this device (message: Signature verification failed), or the timestamp header was unparseable (message: Invalid timestamp). | The SDK splits them on the message, and they are not the same class. A signature that does not verify is terminal — the device's key is no longer the registered one, useSafetlyDriver().unrecoverable goes true, and only reset() / re-enrollment recovers it. An unparseable timestamp is an SDK-side bug, stays retryable, and is never reported as unrecoverable. Do not assume this code means "retry". |
| SIGNATURE_REPLAY | 401 | The request nonce had already been used (message: Nonce already used). | Retryable — the SDK's next attempt generates a fresh nonce. Never terminal. If it recurs, suspect a proxy replaying requests. |
| AUTH_ERROR | 401 | Token or request-signature rejected, with no more specific code. Signed calls to /api/v1/sdk/* now carry the real code (the three signature codes above, plus DEVICE_INACTIVE); the driver endpoints still collapse everything to AUTH_ERROR, and so does an older deployed API. | The SDK classifies on the code where there is one and falls back to reading the error message where there is not (§8), so the behaviour is the same either way: skew re-syncs and retries, a dead device goes unrecoverable, anything else stays retryable. If an AUTH_ERROR reaches your code, treat it as retryable-with-backoff unless useSafetlyDriver().unrecoverable is true. |
| INTERNAL_ERROR | 500 | Server-side fault. | Retry with backoff. |
A note on
DEVICE_SUPERSEDED. Earlier drafts of this integration guide listed aDEVICE_SUPERSEDEDerror code. No such code is returned by the backend — we verified this against the source before publishing. A superseded device is rejected asDEVICE_INACTIVE(fromPOST /api/v1/sdk/refresh) or asAUTH_ERROR(from any signed driver request, because the device no longer resolves to an active row). Branch on those two, not onDEVICE_SUPERSEDED.
8. Token lifetime and recovery
You do not have to do anything to keep a device alive. The device token the SDK receives at enrollment expires after 30 days, and the provider renews it for you:
- Proactively — when the stored token is within 7 days of expiry, the next call refreshes it first.
- Reactively — any signed call that comes back
401triggers one refresh and one retry. - After a clock re-sync — a phone whose clock is far enough off that the
backend rejects the signed timestamp is not a broken device. The SDK
re-reads the server's own clock (an unsigned
GET /health, whoseDateheader it caches as an offset) and retries the refresh once with the corrected time. It is never reported asunrecoverable, because re-enrolling would fail in exactly the same way.
Refresh is itself a signed call (POST /api/v1/sdk/refresh, authorized by
the device's Ed25519 key, not by the token). That is the point: a token copied
off a device is inert without the key, and cannot renew itself.
When a device genuinely cannot continue
If the refresh is refused for a reason that is about the device — it was
superseded by an enrollment elsewhere, revoked, its affiliation revoked, the
publishable key it enrolled with was deactivated, or its signature no longer
verifies against the key Safetly holds — no retry will ever succeed. Only
those signals produce unrecoverable; a skewed clock and an unrecognised
401 stay ordinary retryable errors. The SDK does not spin on it:
const { status, error, unrecoverable, retry, reset } = useSafetlyDriver();
if (status === "error") {
return unrecoverable
? <SafetlyGone message={error?.message} onStartOver={reset} />
: <SafetlyRetry onRetry={retry} />;
}| | retry() | reset() |
|---|---|---|
| What it does | Re-runs restore/enroll with the state already on the device. | Forgets this device — stored identity and TOTP secret — then enrolls from scratch. |
| Use for | Transient failures: offline, a 5xx, a timeout. | An unrecoverable device, when the driver is genuinely on this phone. |
| Cost | None. | Supersedes whatever device row is currently active for this driver, and mints a new secret. It is a real enrollment, and it is audited as one. |
reset() is the "re-enroll" this guide refers to elsewhere. The same operation
is exported as clearDevice(state) for host apps that manage the session
lifecycle themselves; inside the provider, prefer reset().
Note that unrecoverable also covers terminal enrollment failures — a
revoked affiliation, or a bad publishable key. In those cases reset() will
not help either, and the copy you show should say so rather than inviting the
driver to try again.
9. What the SDK never does
Worth stating explicitly, because it bounds your review:
- It never authenticates a user. You do that; it trusts your result.
- It never writes to Safetly. Every driver endpoint it calls is read-only; the only write is the one-time enrollment.
- It never asks for a permission. No camera, no location (you pass coordinates in yourself if you want the nearby list), no notifications, no contacts.
- It never renders a Safetly logo or brand — the screens take your theme.
- It ships no native module. It is plain TypeScript over the peer dependencies
listed in §1, so it needs no prebuild, no config plugin and no
pod installbeyond those peers.
10. A runnable integration, in full
Everything below is the complete minimal Expo app that consumes this package and exercises every screen. It is reproduced here in full, and not merely pointed at, so that a consumer who has only the published package has the whole reference — nothing in this section requires access to the SDK's repository.
1. Create the app and install the peers
npx create-expo-app@latest safetly-example --template blank-typescript
cd safetly-example
npm install @safetly/driver-sdk
npx expo install expo-secure-store expo-crypto react-native-svg
npm install react-native-qrcode-svg2. app.json — add expo-secure-store to plugins. This is a fragment:
merge these keys into the app.json that create-expo-app generated, keeping
its version, icon, splash, ios and android entries.
{
"expo": {
"plugins": ["expo-secure-store"]
}
}The expo-secure-store config plugin is optional — the module works without it
— and it is what lets you configure the parts a plugin owns: the iOS Face ID
usage description (faceIDPermission), needed if you ever enable biometric
access, and Android auto-backup behaviour (configureAndroidBackup), which by
default excludes stored values from backups so a restored phone does not carry
another device's keys. Adding it costs nothing and keeps those knobs available.
3. .env — four values. EXPO_PUBLIC_SAFETLY_API_URL and
EXPO_PUBLIC_SAFETLY_API_KEY are the pair Safetly gives you (§3); the other two
stand in for the driver your own app would have authenticated:
# On a physical Android device this must be your machine's LAN address, not
# localhost — the device resolves localhost to itself.
EXPO_PUBLIC_SAFETLY_API_URL=http://192.168.1.10:3000
EXPO_PUBLIC_SAFETLY_API_KEY=sk_live_replace_me
EXPO_PUBLIC_DRIVER_EXTERNAL_ID=EXAMPLE-1
EXPO_PUBLIC_DRIVER_PHONE=+2126000000014. App.tsx — the whole integration. If this file ever needs to grow, the
SDK is leaking work onto the host app. The view switcher is not a router: it
is local state that your app replaces with its own navigation, and it exists
here only so the example can reach every drop-in screen and their callbacks.
import * as React from "react";
import { Platform, StatusBar, StyleSheet, View, Text, Pressable } from "react-native";
import {
SafetlyDriverProvider,
QRIdentityScreen,
RelayPointsScreen,
RelayPointDetailScreen,
PackagesScreen,
OperationsScreen,
ReceiptScreen,
type RelayPointSummary,
type NearbyRelayPoint,
} from "@safetly/driver-sdk";
// Module-level constant, not an inline object literal: the provider takes
// `config` by reference, and a new object every render re-runs the
// enroll/restore effect. (A JSON import, per §3, is a constant for free.)
const config = {
apiUrl: process.env.EXPO_PUBLIC_SAFETLY_API_URL ?? "",
apiKey: process.env.EXPO_PUBLIC_SAFETLY_API_KEY ?? "",
};
type Screen =
| { name: "qr" }
| { name: "relayPoints" }
| { name: "relayPointDetail"; relayPointId: string }
| { name: "packages"; relayPointId: string }
| { name: "operations" }
| { name: "receipt"; operationId: string };
// Plain padding instead of a safe-area component, deliberately. `SafeAreaView`
// from `react-native` is deprecated as of RN 0.86, and its replacement lives in
// `react-native-safe-area-context` — a package the SDK does NOT depend on,
// because a UI library that drags a native module into every host app is hard
// to adopt. Your app almost certainly already has it (Expo Router and React
// Navigation both require it): use `useSafeAreaInsets()` and delete this.
export default function App(): React.ReactElement {
const [view, setView] = React.useState<Screen>({ name: "qr" });
return (
<View style={styles.root}>
<StatusBar barStyle="dark-content" />
<SafetlyDriverProvider
config={config}
driverExternalId={process.env.EXPO_PUBLIC_DRIVER_EXTERNAL_ID ?? "EXAMPLE-1"}
phone={process.env.EXPO_PUBLIC_DRIVER_PHONE ?? "+212600000001"}
name="Example Driver"
>
<View style={styles.nav}>
<NavButton label="QR" active={view.name === "qr"} onPress={() => setView({ name: "qr" })} />
<NavButton
label="Relay points"
active={view.name === "relayPoints"}
onPress={() => setView({ name: "relayPoints" })}
/>
<NavButton
label="Operations"
active={view.name === "operations"}
onPress={() => setView({ name: "operations" })}
/>
</View>
{view.name === "qr" && <QRIdentityScreen />}
{view.name === "relayPoints" && (
<RelayPointsScreen
onSelectRelayPoint={(relayPoint: RelayPointSummary | NearbyRelayPoint) =>
setView({ name: "relayPointDetail", relayPointId: relayPoint.id })
}
/>
)}
{view.name === "relayPointDetail" && (
<RelayPointDetailScreen
relayPointId={view.relayPointId}
onViewPackages={(relayPointId) => setView({ name: "packages", relayPointId })}
/>
)}
{view.name === "packages" && <PackagesScreen relayPointId={view.relayPointId} />}
{view.name === "operations" && (
<OperationsScreen
onSelectOperation={(operationId) => setView({ name: "receipt", operationId })}
/>
)}
{view.name === "receipt" && <ReceiptScreen operationId={view.operationId} />}
</SafetlyDriverProvider>
</View>
);
}
function NavButton({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}): React.ReactElement {
return (
<Pressable style={[styles.navButton, active && styles.navButtonActive]} onPress={onPress}>
<Text style={[styles.navButtonLabel, active && styles.navButtonLabelActive]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#FFFFFF",
paddingTop: Platform.OS === "android" ? (StatusBar.currentHeight ?? 24) : 44,
},
nav: { flexDirection: "row", padding: 8, gap: 8, borderBottomWidth: 1, borderBottomColor: "#E5E7EB" },
navButton: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, backgroundColor: "#F5F5F5" },
navButtonActive: { backgroundColor: "#0A0A0A" },
navButtonLabel: { fontSize: 13, color: "#0A0A0A" },
navButtonLabelActive: { color: "#FFFFFF" },
});5. Run it
npx expo startExpo Go is enough: expo-secure-store and expo-crypto both run there. Move to
a development build (npx expo run:android / run:ios) once you want the
app.json plugin settings above to take effect, since config plugins apply at
prebuild and Expo Go ignores them.
Only the local-path case needs anything more: if you consume the SDK from
file:../sdk rather than from the registry, add the metro.config.js from §1
so Metro does not resolve two copies of React Native.
