Docs/Mobile SDK/Getting Started

React Native

@octetproof/react-native binds the native iOS and Android SDKs for React Native. Ask whether a device is inside or outside a region, and get back a signed, hardware-attested proof that your backend can verify.

The wrapper pins one exact native SDK version, the same on iOS and Android, so a proof from React Native means the same as a proof from the native SDK. Version 0.1.0 uses native SDK 3.1.0.

Requirements

React Native 0.71 or later, with the New Architecture or the legacy bridge
iOS 16.0 or later, on a physical device
Android API 30 (Android 11) or later
Android ABI arm64-v8a only

The Android SDK ships arm64-v8a only. There is no x86_64 build, so the standard Android emulator doesn't work: develop on a physical arm64 device. The iOS Simulator can't attest the app either, so use a real iPhone.

Install

npm install @octetproof/react-native

With Expo, add the config plugin and you're done. In a bare React Native app, make the iOS and Android changes below.

Expo

Add the plugin to app.json, then run npx expo prebuild:

{ "expo": { "plugins": ["@octetproof/react-native"] } }

The plugin sets the Info.plist permission strings, the iOS deployment target, Octet's Maven repository and minSdkVersion.

For proofs while the app is in the background, turn on backgroundLocation. It adds the always-location key and the location background mode:

{ "expo": { "plugins": [["@octetproof/react-native", { "backgroundLocation": true }]] } }

The plugin's permission strings are placeholders. Apple's reviewers read them and prefer a specific reason, so write your own. The plugin never replaces a value you have already set:

Option Sets
locationWhenInUsePermission NSLocationWhenInUseUsageDescription
motionPermission NSMotionUsageDescription
locationAlwaysPermission NSLocationAlwaysAndWhenInUseUsageDescription, only when backgroundLocation is true
backgroundLocation The always-location key and UIBackgroundModes

iOS

Set the deployment target to 16.0 first. React Native's default is lower, and CocoaPods then fails with a message that doesn't name the version. In ios/Podfile, replace platform :ios, min_ios_version_supported with:

platform :ios, '16.0'

Then install the pods:

cd ios && pod install

pod install downloads the native framework and checks its checksum, so the first run needs network access. A checksum mismatch fails the install.

Android

Make two changes to your app.

  1. Set minSdkVersion to 30 in android/build.gradle.
  2. Add Octet's Maven repository to your app. It has to be in your app, because a library can't add repositories for its own dependencies. Without it, the build fails with Could not find com.octetproof:sdk:<version>. In the default React Native template, that's android/build.gradle:
allprojects {
  repositories {
    maven { url 'https://raw.githubusercontent.com/octetproof/octet-sdk-android/mvn-repo' }
  }
}

If your app declares repositories with dependencyResolutionManagement in android/settings.gradle, add it there instead.

Permissions

Ask for location permission before Octet.start. The SDK needs a location permission to start, precise or approximate. With approximate location only, country proofs work, finer levels return INSUFFICIENT_PRECISION, and a proof is at most PLAUSIBLE. On iOS, the location and motion usage strings are required: without the motion string, iOS ends the app the first time the SDK reads motion data. The Expo plugin sets both strings. For a bare app, see Prerequisites.

Ask your first question

import { Octet, OctetRegion } from '@octetproof/react-native';

const sdk = await Octet.start();

const verdict = await sdk.loc.isWithin(OctetRegion.country('US'));

switch (verdict.result) {
  case 'YES':
  case 'NO':
    // A YES or NO always carries a proof.
    sendToVerifier(verdict.proof.proof_bytes_b64);
    break;
  case 'INDETERMINATE':
    // Not a NO: the SDK can't answer right now.
    console.log(verdict.reason, verdict.achievable_level);
    break;
}

Octet.start() needs no config. It activates by attesting the app instance, as on native.

Read the result, never its truthiness

result is YES, NO or INDETERMINATE. INDETERMINATE means the SDK can't answer right now, for example while it warms up or when the precision you asked for isn't available. Never treat it as NO. See Verdicts.

TypeScript won't let you read verdict.proof until you narrow on result. It can't catch this, though: result is always a non-empty string, so if (verdict.result) is true for a NO too. Always compare:

if (verdict.result === 'YES') { grantAccess(); }

The lint rule @typescript-eslint/strict-boolean-expressions catches the mistake.

When reason is INSUFFICIENT_PRECISION, achievable_level names the best level the SDK can prove right now. The SDK never answers a coarser question than the one you asked.

Regions

Build regions with the factories. The native SDK checks every region, and rejects a malformed one.

OctetRegion.country('US')
OctetRegion.subdivision('US-CA')
OctetRegion.usState('CA')              // same as subdivision('US-CA')
OctetRegion.city('San Francisco')
OctetRegion.disc({ latitude: 37.422, longitude: -122.084 }, 500)
OctetRegion.ellipse(center, 900, 400, 31.5)
OctetRegion.box3D([37, 38], [-122.5, -122], [0, 500])
OctetRegion.polygonSet(['8a2a1072b59ffff'])
OctetRegion.earth()

Pass H3 cells as hex strings. An H3 index is too large for a JavaScript number, so the wrapper rejects numeric cells. See Regions.

Predicate options

Every predicate takes an optional PredicateOpts object. It holds the parameters that Swift and Kotlin take as default arguments:

Field Meaning
atTime The Date to answer for. Defaults to now.
sessionNonceB64 Your per-login challenge, base64, 1 to 512 bytes. See Session binding.
decisionRef Your decision id, committed into the proof. See Decision binding.
content Content to commit to the proof. See Content.

An empty or oversized sessionNonceB64 returns an INVALID_SESSION_NONCE verdict with no proof. It doesn't throw.

Content

content commits your content to the proof by digest. The content itself never enters the proof. Each item has one of:

  • valueB64: bytes in memory, base64.
  • fileUri: a file of up to 64 MiB.
  • digest: a digest from Octet.hashContent, for larger files.

The SDK salts content by default. A valueB64 or fileUri item without saltB64 gets a fresh 32-byte salt, which the SDK writes onto your item object during the call. Read item.saltB64 afterwards and keep it with the content, because a verifier needs it. Pass saltB64: '' to commit without a salt. Octet.hashContent returns its salt as saltB64.

Caller content must be enabled for your license. See Content Commitments.

Uploading content to your storage

To have the SDK upload committed content to your own storage after the proof is made, set upload: true on the item (or uploadFromUri and uploadSaltB64 on a digest item). Then register a provider that returns a fresh upload target for each attempt:

Octet.setContentUploadProvider(async (request) => {
  const url = await myBackend.presignUpload(request.proofId, request.key, request.size);
  return { url, headers: { 'Content-Type': request.contentType } };
});
Octet.setContentUploadListener((status) => console.log(status.key, status.state, status.failure));

const verdict = await sdk.loc.isWithin(OctetRegion.country('US'), {
  content: [{ key: 'photo', contentType: 'image/jpeg', fileUri, upload: true, locator: 'site-42/photo' }],
});
if (verdict.proof) {
  const statuses = await Octet.contentUploads(verdict.proof.id);
}

The proof comes back before any upload starts, and an upload never changes it. The upload request carries saltB64, so you can store it with the file. Each status is pending, uploading, uploaded or failed, and a failure names its reason: contentChanged, contentMissing, providerFailed, rejected (with httpStatus) or exhausted.

Uploads run in the foreground only. The provider is JavaScript, so it can answer only while the app's JavaScript runs. When the OS starts your app in the background, no provider is registered, and queued uploads wait until the app runs again, for up to 7 days. Register the provider early, for example when the app starts. A provider that throws, returns no url, or doesn't answer within 60 seconds counts as a failed attempt, and the SDK retries.

Resumable uploads

For large files, return a resumable target. The SDK then sends the content in chunks and continues from the last confirmed chunk after an interruption:

Target What your backend supplies
{ kind: 'gcsResumable', url, headers? } A Google Cloud Storage resumable session URL it has opened.
{ kind: 'azureBlock', url, headers? } The blob's Azure SAS URL. The SDK uploads blocks, then commits the block list.
{ kind: 's3Multipart', uploadId } An S3 multipart upload it has started.

The SDK sends a resumable target's headers with every request. An s3Multipart target needs two more callbacks, passed as the second argument. s3PartTarget returns the pre-signed URL for one part ({ url, method?, headers? }), and s3Complete completes the upload with the parts (partNumber and etag), in order:

Octet.setContentUploadProvider(
  async (request) => ({ kind: 's3Multipart', uploadId: await myBackend.startMultipart(request) }),
  {
    s3PartTarget: async (request, uploadId, partNumber) =>
      ({ url: await myBackend.presignPart(uploadId, partNumber) }),
    s3Complete: async (request, uploadId, parts) => myBackend.completeMultipart(uploadId, parts),
  },
);

Each callback also has 60 seconds. A rejection or a timeout counts as a failed attempt, and a failed s3Complete is retried without sending the parts again. Without the S3 callbacks, the SDK refuses an s3Multipart target as a failed attempt.

Errors

Octet.start rejects with a LicenseError that carries a code. Predicates return a verdict for everything the SDK decides, including INDETERMINATE. They reject only when the call can't reach the SDK: with SDK_NOT_RUNNING before start has completed, INVALID_REGION for a region the bridge can't decode, and INVALID_CALLER_CONTENT for content it can't decode. Octet.hashContent rejects with INVALID_CALLER_CONTENT.

The codes are MALFORMED_KEY, VERIFICATION_FAILED, NO_ACTIVATION, EXPIRED, ACTIVATION_WINDOW_CLOSED, REVOKED, NETWORK, UPGRADE_REQUIRED, SERVER_REJECTED, NOT_YET_RELEASED, BOOTSTRAP_FAILED, NEW_DEVICES_BLOCKED and START_FAILED.

  • UPGRADE_REQUIRED carries minVersion, SERVER_REJECTED carries httpStatus, and VERIFICATION_FAILED carries verificationReason.
  • BOOTSTRAP_FAILED means the attested bootstrap was refused. The message names the reason, for example APP_NOT_REGISTERED or DEVICE_LIMIT_REACHED. See Attested bootstrap.
  • START_FAILED is a start failure that isn't a license problem. The message says what failed.
  • NEW_DEVICES_BLOCKED means your account accepts no new devices. Devices that already activated keep working. Send the user to upgradeUrl, opened exactly as received: the link is server-signed, so never build one yourself.

See License Types.

try {
  await Octet.start();
} catch (e) {
  if (e instanceof LicenseError && e.code === 'UPGRADE_REQUIRED') {
    promptUpgrade(e.minVersion);
  }
}

Proofs

proof_bytes_b64 is the proof: base64 of the serialized proof, which only a verifier reads. Send it as it is, without parsing or re-encoding it. The other proof fields are there to inspect on the device.

proof_bytes_b64 doesn't show up when you log a verdict

You read proof_bytes_b64 as usual, but it's hidden from anything that walks the object, such as JSON.stringify(verdict), console.log(verdict) or { ...verdict.proof }. Most React Native tools capture whole objects (a log line, a crash report breadcrumb, a store saved to AsyncStorage), so this keeps the proof out of them by accident.

It is a safeguard, not a guarantee. The proof crosses the bridge as a string, so it sits in JavaScript memory until garbage collection, and a tool that reads Object.getOwnPropertyNames still sees the field. If that matters to you, keep the proof off the device, as below.

When you forward a verdict, name the field, because a spread leaves it out:

await fetch(url, {
  method: 'POST',
  body: JSON.stringify({
    result: verdict.result,
    proof_bytes_b64: verdict.proof.proof_bytes_b64,   // a spread drops it
  }),
});

Keeping proofs off the device

Your backend can fetch the proof itself, so the proof never enters JavaScript. Pass a decisionRef, send it to your backend instead of the proof, and have your backend fetch the proof by it:

const verdict = await sdk.loc.isWithin(OctetRegion.country('US'), { decisionRef });
await fetch('/my-backend/decide', {
  method: 'POST',
  body: JSON.stringify({ decisionRef, result: verdict.result }),   // no proof bytes
});

This needs proofUploadUrl, because proof upload is off by default. The verdict returns before the upload finishes, so your backend retries until the proof is there. That's fine for an audit trail or a decision that can wait. When a user is waiting on a screen, send the proof bytes instead.

What the wrapper doesn't include

  • Octet.verify. Check proofs on your backend with octet-verify, or in a native module with Octet.verify.
  • acceptPlausibleContainment. A disc query answers only from a VERIFIED proof. See Predicates.
  • TransportPolicy, so the SDK's calls can't go through a gateway. See Gateway routing.
  • AdvancedConfig.foregroundNotification on Android. See AdvancedConfig.
  • Log sinks, the region-builder DSL, the canonical region-string helper and the feature-flag getters.

Usage reporting works as on native. See Data collection.

Versions

The wrapper has its own version and pins an exact native SDK version, the same on iOS and Android. From 1.0.0 its major version matches the native SDK's. While it's 0.x, the majors are independent. Minor and patch versions always move on their own, so a fix in JavaScript doesn't need a new native release.

@octetproof/react-native Native SDK
0.1.x 3.1.0

Octet.sdkVersion returns the native SDK version at runtime, not the wrapper's. The pinned versions are also in node_modules/@octetproof/react-native/native-pin.json.

Next steps

TermsPrivacy© 2026 Understone, Inc. d/b/a Octet