npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-svg

The 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 undefined

If 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 logisticsCompanyId prop, §4) is refused with 403 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, your tsconfig.json needs "resolveJsonModule": true (Expo's expo/tsconfig.base sets 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, a config/safetly.production.ts exporting the same object works identically — the provider only cares that the object is a module-level constant.

Pass config by reference. The provider takes config as an effect dependency by identity. A fresh object literal on every render (config={{ apiUrl, apiKey }}) re-runs the enroll/restore effect on every render, flickers status back 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 through useMemo.

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:

  • apiUrl must be present, a non-empty string, and an http:// or https:// 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 bare https:// with no host, a query string, a fragment, or whitespace inside the host are each rejected: none of them survives having /api/v1/... appended.
  • apiKey must be present, a non-empty string, and start with sk_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 driverExternalId will 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" |

logisticsCompanyId is 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 with 403 COMPANY_NOT_IN_SCOPE rather 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:

  1. Your driverExternalId is not unique — two drivers are sharing one, and they will keep knocking each other offline forever. This is a data bug on your side.
  2. 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 Date header 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 a DEVICE_SUPERSEDED error code. No such code is returned by the backend — we verified this against the source before publishing. A superseded device is rejected as DEVICE_INACTIVE (from POST /api/v1/sdk/refresh) or as AUTH_ERROR (from any signed driver request, because the device no longer resolves to an active row). Branch on those two, not on DEVICE_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 401 triggers 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, whose Date header it caches as an offset) and retries the refresh once with the corrected time. It is never reported as unrecoverable, 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 install beyond 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-svg

2. 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=+212600000001

4. 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 start

Expo 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.