# Octet SDK Documentation — full text Generated from the canonical markdown in octetproof/octet-site (Mobile SDK + Browser SDK). =================== MOBILE SDK =================== # Octet Mobile SDK Provable location for iOS and Android apps. The SDK is distributed on GitHub (add it via Swift Package Manager or Maven). It needs a license key to run, and a key is at [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup): the first 1,000 active users each month are free, no credit card. The Octet SDK turns a location check into a verdict (`YES`, `NO`, or `INDETERMINATE`) and, when it can answer, a signed proof anyone can verify offline with [`octet-verify`](https://github.com/octetproof/octet-verify), the open-source verifier. :::note[SDK 1.2.1] This documentation describes the Octet SDK 1.2.1. Versions 1.0.0, 1.1.0, and 1.2.0 are deprecated for security reasons: pin 1.2.1 or later. Three declared symbols remain unwired: `getRegion(city)`, `getRegion(namedZone)`, and proto-JSON serialization for proof material. Calling them throws `OctetFutureFlag`. ::: ## Pick a starting point - **Shipping an iOS app** → [iOS Quick Start](/getting-started/ios-quickstart) - **Shipping an Android app** → [Android Quick Start](/getting-started/android-quickstart) - **Evaluating the SDK** → [Proof of Location](/concepts/proof-of-location) - **Seeing what your users experience** → [UX](https://octetproof.com/ux/) - **Looking up a symbol** → [API Reference](/api-reference/overview) - **Upgrading from 1.0 or 1.1** → [What's new in 1.2](/whats-new) - **Debugging an error** → [Troubleshooting & FAQ](/troubleshooting/faq) ## What the SDK does You call `sdk.loc.isWithin(region: …)`. You get back a [`Verdict`](/api-reference/octet-verdict) of `YES`, `NO`, or `INDETERMINATE`, and on `YES` or `NO` a cryptographic proof a third party can verify offline. ## What this documentation covers - [**Getting Started**](/getting-started/prerequisites). Prerequisites, install, first verdict. - [**Concepts**](/concepts/proof-of-location). What proofs are, the verdict values, regions, time semantics, and the license model. - [**API Reference**](/api-reference/overview). Per-symbol pages, Swift and Kotlin side by side. - [**Sample Apps**](/samples/ios-toy-app). The OctetSample demo apps for iOS and Android. - [**Troubleshooting**](/troubleshooting/faq). Indexed by what you see in `verdict.reason` or `LicenseError`. - [**UX**](https://octetproof.com/ux/). What the person sees in your app while the SDK works, beat by beat, with the trace beside it. ## What the SDK is *not* - **Not a navigation library.** It reports whether a device was at a claimed place at a claimed time, not where to go next. - **Not a spoof detector you can wire into your own user-flagging.** Spoof signals fold into the verdict. You get a `reason` code (for example `ATTESTATION_FAILED`), not the sensor-level detail of which signal fired. Exposing that is a security risk: it tells an attacker exactly what to defeat. _Source: https://octetproof.com/docs/_ --- # What's new in 1.2 The latest Mobile SDK is **1.2.1**, released 2026-08-03 on iOS and Android in lockstep. 1.2.1 is a security hotfix on top of 1.2.0, and 1.2.0 was a feature release on top of 1.1.0. This page covers the whole 1.1 to 1.2.1 span, so an app upgrading from 1.1 sees everything that changed. :::caution[Upgrade to 1.2.1] Versions 1.0.0, 1.1.0, and 1.2.0 are deprecated for security reasons. Their release artifacts have been removed from the GitHub release pages, and from the Maven repo on Android, so a build pinned to one of them no longer resolves. Pin 1.2.1 or later. ::: ## Upgrade iOS (Swift Package Manager): ```swift .package(url: "https://github.com/octetproof/octet-sdk-ios", exact: "1.2.1") ``` Android (Gradle): ```kotlin implementation("com.octetproof:sdk:1.2.1") ``` The proof wire format, proof semantics, trust levels, and verdict codes are unchanged across 1.2. A 1.2.1 proof means what a 1.1 proof means, and every verifier keeps working. The one thing that needs a newer verifier is enforcing the new session binding, which requires `octet-verify` 1.2.0 or later. ## New API - **Session-binding.** `isWithin`, `isOutside`, and `contains` take an optional `sessionNonce` (1 to 512 bytes). The SDK commits a hash of the nonce inside the signed proof, so your verifier can confirm the proof was made for a specific login. An empty or over-cap nonce returns `INVALID_SESSION_NONCE`. See [Session-binding](/concepts/session-binding) and [Predicates](/api-reference/predicates). - **`Octet.attestationEnrolmentBundle()`.** Returns this device key's attestation evidence, so a verifier can establish the device's hardware root before the first proof arrives. See [`attestationEnrolmentBundle()`](/api-reference/octet-start) and [Device Attestation](/concepts/device-attestation). - **SDK version gating.** The SDK reports its version and platform on every backend request. `Octet.start(...)` can throw `LicenseError.upgradeRequired(...)`, and `LicenseStatus` gains `upgradeRecommended` and `minSupportedVersion`. Both paths are inert in 1.2, because the backend gates no version yet. See [License Types](/api-reference/license-types). ## Changed - **Rolling license-token persistence.** The SDK persists the refreshed license token from a lease response, so a device carries a fresh token across restarts within the offline-grace window. See [License & Activation](/concepts/license-activation). - **Android background location is now opt-in.** The SDK's Android manifest declares only the permissions the foreground proof flow uses, so it no longer bundles `ACCESS_BACKGROUND_LOCATION`. To generate proofs while the app is backgrounded, declare `ACCESS_BACKGROUND_LOCATION` in your own manifest. See [Prerequisites](/getting-started/prerequisites). - **Android public API surface narrowed.** Several internal `com.octetproof.sdk.model` types that were public by default on Android are now `internal`, matching iOS. `Position` and `GeoBounds` stay public. If you referenced an undocumented type, switch to the public `Octet` API. ## Fixed (Android) - **Device-key security level carried on the wire.** A 1.1 serialization bug dropped `device_attestation.security_level` from the proof, so a verifier gating on a hardware tier could reject a healthy TEE or StrongBox device. A 1.2 proof reports the true tier. - **Attestation key re-attested before its chain expires.** The key-attestation chain's intermediate is short-lived (about 14 days). The SDK now regenerates the key before the chain ages out, rather than presenting an aged-out chain about two weeks after enrolment. ## Privacy and distribution - **iOS privacy manifest.** The xcframework bundles `PrivacyInfo.xcprivacy`, declaring the data types it handles and its required-reason API use. Xcode folds it into your app's privacy report. - **Android data-collection disclosure.** The SDK's `INTEGRATION.md` gains a section describing what the SDK handles, by purpose, to help you complete the Play Console Data Safety form. - **Android 16 KB page-size support.** The native libraries are aligned for the 16 KB memory-page devices that Android 15 introduces. No action needed. - **Signed, verifiable downloads.** Each release publishes a SHA-256 checksum, SLSA build provenance, an SBOM (software bill of materials), and a keyless cosign signature for the binary. See "Verifying the download" in each repository's `INTEGRATION.md`. ## Full changelogs - [`octet-sdk-ios` CHANGELOG](https://github.com/octetproof/octet-sdk-ios/blob/main/CHANGELOG.md) - [`octet-sdk-android` CHANGELOG](https://github.com/octetproof/octet-sdk-android/blob/main/CHANGELOG.md) _Source: https://octetproof.com/docs/whats-new/_ --- # Prerequisites Three things every consumer app needs before calling `Octet.start(...)`: 1. A **license key**. 2. **Privacy declarations** in your app bundle (iOS `Info.plist`) or runtime permission grants (Android). 3. A **minimum platform version**. --- ## 1. License key The SDK is gated by a per-developer license key. Request a free key at [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup). The key is a PASETO v4.public token shaped like `octet_live_v4.public.…` (or `octet_test_…` against staging). Octet signs the key, and the SDK verifies the signature locally before any network call. The **first 1,000 active users each month are free**, and past that usage is billed at **one US cent per active user, per month**. An active user is a device that asked your app for at least one proof that month. The SDK reports a count per device, per month, and nothing else: no identity, no coordinates. A device that proves a thousand times counts once. Keys renew automatically, with no time limit, and there is no per-license device cap: install on as many devices as you need. --- ## 2. Platform privacy declarations ### iOS: `Info.plist` keys Add these to your app's `Info.plist`. Without them, the iOS runtime crashes on first launch with a privacy-sensitive-data error that names the missing key. #### Required ```xml NSLocationWhenInUseUsageDescription This app uses your location to verify and prove your location to services that request it. NSMotionUsageDescription This app uses motion data to detect when you're stationary or moving, which improves the confidence of location proofs. ``` `NSMotionUsageDescription` is required. The SDK touches `CMMotionActivityManager` immediately during `Octet.start(...)`, and Apple requires the usage description before any code accesses that API. Read-only access also counts. The strings are user-facing. The copy above is a safe default. Rewrite in your product's voice if you prefer. #### Required only if you enable background location If your app needs proofs while backgrounded, also add: ```xml NSLocationAlwaysAndWhenInUseUsageDescription This app uses background location to continue generating location proofs while you're not actively using it. UIBackgroundModes location ``` Without these the SDK silently falls back to foreground-only operation when the app is backgrounded. The SDK itself does not crash. Proofs stop generating until the app returns to foreground. ### Android: runtime permissions The SDK's `AndroidManifest.xml` declares the permissions the on-demand foreground proof flow uses: location, motion, foreground service, internet, and wake-lock. Manifest-merge propagates these into your app, so you do not copy them into your own manifest. The SDK's manifest declares only the permissions the foreground proof flow uses, so `ACCESS_BACKGROUND_LOCATION` is not bundled. To generate proofs while the app is backgrounded, declare `ACCESS_BACKGROUND_LOCATION` in your own manifest. You still request the runtime permissions from the user: | Permission | When to request | Notes | |---|---|---| | `ACCESS_FINE_LOCATION` | Before `Octet.start(...)`. | SDK refuses to start without it. | | `ACTIVITY_RECOGNITION` | Before `Octet.start(...)`. | Android 10+ (API 29+). Motion-classification features degrade gracefully if denied. Request it for full proof confidence. | | `ACCESS_BACKGROUND_LOCATION` | After `ACCESS_FINE_LOCATION` is granted, only if you need background proofs. | Declare it in your own manifest first. The SDK does not merge it. Android 10+ prompts for it separately. | --- ## 3. Minimum platform versions | Platform | Minimum | Toolchain | |---|---|---| | iOS | 16.0 | Xcode 15+, Swift 5.9+ | | Android | API 30 (Android 11) | Android Studio Hedgehog (2023.1.1)+, JDK 17, Kotlin 2.1+ | --- Next: the [iOS Quick Start](/getting-started/ios-quickstart) or [Android Quick Start](/getting-started/android-quickstart). _Source: https://octetproof.com/docs/getting-started/prerequisites/_ --- # iOS Quick Start From zero to a `YES` verdict on an iOS device in ten minutes. :::note Work through [Prerequisites](/getting-started/prerequisites) first. You will need a license key and the two `Info.plist` keys. ::: --- ## 1. Add the SDK ### Swift Package Manager (recommended) In Xcode: **File → Add Packages…** and enter: ``` https://github.com/octetproof/octet-sdk-ios ``` Pin to a version rather than tracking `main`. Or in `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/octetproof/octet-sdk-ios", exact: "1.2.1") ] ``` Pin 1.2.1 or later. Versions 1.0.0, 1.1.0, and 1.2.0 are deprecated for security reasons, and their release artifacts have been removed from GitHub, so a build pinned to one of them no longer resolves. Then import: ```swift import OctetSDK ``` ### Carthage Carthage does not propagate SwiftPM transitive dependencies. Add **two** lines to your `Cartfile`: ``` binary "https://raw.githubusercontent.com/octetproof/octet-sdk-ios/main/OctetSDK.json" >= 1.2.1 github "apple/swift-protobuf" ~> 1.28 ``` The module name is `OctetSDK`. ### Verify the download (optional) Each release publishes a SHA-256 checksum, SLSA build provenance, an SBOM (software bill of materials), and a keyless cosign signature for `OctetSDK.xcframework`. To check them in a release pipeline, follow the "Verifying the download" section of [`INTEGRATION.md`](https://github.com/octetproof/octet-sdk-ios/blob/main/INTEGRATION.md). A build runs without this step. --- ## 2. Add `Info.plist` keys ```xml NSLocationWhenInUseUsageDescription This app uses your location to verify and prove your location to services that request it. NSMotionUsageDescription This app uses motion data to detect when you're stationary or moving, which improves the confidence of location proofs. ``` Without these, the app crashes on first launch. The xcframework also bundles a privacy manifest (`PrivacyInfo.xcprivacy`) declaring the data types it handles (location, device identifier, aggregate usage counters) and its required-reason API use. Xcode folds this into your app's privacy report at build time, so you do not re-declare the SDK's data use. --- ## 3. Request location permission The SDK refuses to start until the user grants location authorization. Request it before calling `Octet.start(...)`: ```swift import CoreLocation let locationManager = CLLocationManager() locationManager.requestWhenInUseAuthorization() ``` Wait for the authorization status callback (`locationManagerDidChangeAuthorization`) before continuing. --- ## 4. Start the SDK :::tip[Don't have a key yet?] `licenseKey` is a placeholder. Get a key (the first 1,000 active users each month are free, no credit card) at **[sdk.octetproof.com/signup](https://sdk.octetproof.com/signup)**, then paste it in. ::: ```swift import OctetSDK let config = OctetConfig(licenseKey: "octet_live_v4.public.…") let sdk = try await Octet.start(config: config) ``` `Octet.start(...)` is `async throws`. On first launch the SDK verifies the license key locally, exchanges it for an activation token via `api.octetproof.com/v1/activate`, caches the token in Keychain, and brings up the proof pipeline. On subsequent launches the cached token is reused. Any license problem throws a typed `LicenseError`. See [License Types](/api-reference/license-types) for the case list. --- ## 5. Ask your first question ```swift let verdict = await sdk.loc.isWithin( region: .country(isoCode: "US"), atTime: Date() ) switch verdict.result { case .yes: print("YES, proof attached: \(verdict.proof != nil)") case .no: print("NO, provable negative") case .indeterminate: print("INDETERMINATE, reason: \(verdict.reason)") } ``` The predicate returns an [`OctetVerdict`](/api-reference/octet-verdict). Never treat `INDETERMINATE` as `NO`. --- ## 6. What to expect - **On a real device, outdoors**, with cellular and GPS available, `isWithin(.country(isoCode: ...))` typically returns `YES` with an attached proof. - **On the iOS Simulator** the verdict will always be `INDETERMINATE / NO_FIX` with the message `running on simulator — location proofs are unavailable in this environment`. This is by design. The simulator has no GNSS or motion stack, and the spoof-detection pipeline blocks proof generation. **Run on hardware** to see the full flow. - **On a real device, indoors**, the first proof may take longer or come back at `MEDIUM` confidence. See [Concepts: Verdicts](/concepts/verdicts) for how confidence relates to the verdict. --- ## 7. From here - The [OctetSample sample app](/samples/ios-toy-app) is a single-button SwiftUI app that exercises this whole flow. - [Concepts: Proof of Location](/concepts/proof-of-location) explains what a verdict proves. - [Session-binding](/concepts/session-binding) ties a proof to a specific login, so your verifier can confirm the proof was made for that login. - [Verifying Proofs](/concepts/verifying-proofs) and the [Verifier Quick Start](/getting-started/verifier-quickstart) show how anyone can independently check the proofs your app produces. - [What's new in 1.2](/whats-new) lists the API added since 1.1. - [API Reference Overview](/api-reference/overview) maps the public surface. _Source: https://octetproof.com/docs/getting-started/ios-quickstart/_ --- # Android Quick Start From zero to a `YES` verdict on an Android device in ten minutes. :::note Work through [Prerequisites](/getting-started/prerequisites) first. You will need a license key and a plan for runtime permissions. ::: --- ## 1. Add the Maven repo In your project's root `settings.gradle.kts`: ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://raw.githubusercontent.com/octetproof/octet-sdk-android/mvn-repo") } } } ``` The OctetSDK Maven artifacts live on the `mvn-repo` orphan branch of the public `octet-sdk-android` repository. --- ## 2. Add the dependency In your app `build.gradle.kts`: ```kotlin dependencies { implementation("com.octetproof:sdk:1.2.1") } ``` Pin 1.2.1 or later. Versions 1.0.0, 1.1.0, and 1.2.0 are deprecated for security reasons, and their artifacts have been removed from the Maven repo, so a build pinned to one of them no longer resolves. Sync Gradle. The SDK's foreground permissions merge into your manifest automatically, so you declare none of them yourself. For proofs while the app is backgrounded, add `ACCESS_BACKGROUND_LOCATION` to your own manifest. See [Prerequisites](/getting-started/prerequisites). ### Verify the download (optional) Each release publishes a SHA-256 checksum, SLSA build provenance, an SBOM (software bill of materials), and a keyless cosign signature for the AAR. To check them in a release pipeline, follow the "Verifying the download" section of [`INTEGRATION.md`](https://github.com/octetproof/octet-sdk-android/blob/main/INTEGRATION.md). A build runs without this step. --- ## 3. Request runtime permissions The SDK refuses to start without `ACCESS_FINE_LOCATION`. Motion-classification confidence degrades without `ACTIVITY_RECOGNITION`. Request both before calling `Octet.start(...)`: ```kotlin ActivityCompat.requestPermissions( this, arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACTIVITY_RECOGNITION, ), REQUEST_CODE ) ``` Wait for `onRequestPermissionsResult(...)` to confirm `ACCESS_FINE_LOCATION` was granted before continuing. --- ## 4. Start the SDK :::tip[Don't have a key yet?] `licenseKey` is a placeholder. Get a key (the first 1,000 active users each month are free, no credit card) at **[sdk.octetproof.com/signup](https://sdk.octetproof.com/signup)**, then paste it in. ::: ```kotlin import com.octetproof.sdk.api.Octet import com.octetproof.sdk.api.OctetConfig lifecycleScope.launch { val sdk = Octet.start( context = applicationContext, config = OctetConfig(licenseKey = "octet_live_v4.public.…") ) // sdk is ready } ``` `Octet.start(...)` is a `suspend` function. Call it from a coroutine scope (`lifecycleScope`, `viewModelScope`, or your own). On first launch the SDK verifies the license key locally, exchanges it for an activation token via `api.octetproof.com/v1/activate`, caches the token in `EncryptedSharedPreferences`, and brings up the proof pipeline. Any license problem throws a typed `LicenseError`. See [License Types](/api-reference/license-types). --- ## 5. Ask your first question ```kotlin import com.octetproof.sdk.api.OctetRegion import com.octetproof.sdk.api.OctetVerdict import java.time.Instant val verdict = sdk.loc.isWithin( region = OctetRegion.country("US"), atTime = Instant.now() ) when (verdict.result) { OctetVerdict.Result.YES -> println("YES, proof attached: ${verdict.proof != null}") OctetVerdict.Result.NO -> println("NO, provable negative") OctetVerdict.Result.INDETERMINATE -> println("INDETERMINATE, reason: ${verdict.reason}") } ``` The predicate returns an [`OctetVerdict`](/api-reference/octet-verdict). Never treat `INDETERMINATE` as `NO`. --- ## 6. What to expect - **On a real device, outdoors**, with cellular and GPS available, `isWithin(country("US"))` typically returns `YES` with an attached proof. - **On the Android emulator** the verdict will always be `INDETERMINATE / NO_FIX` with the message `running on emulator — location proofs are unavailable in this environment`. This is by design. The emulator's mock-location flag blocks proof generation. **Run on hardware** to see the full flow. - **On a real device, indoors**, the first proof on Android usually arrives quickly via the cell-tower MCC signal even without GPS. `isWithin(country(...))` typically succeeds with `HIGH` confidence indoors on Android. Finer-grained predicates (city, polygon) may need a GPS fix. See [Concepts: Verdicts](/concepts/verdicts). --- ## 7. From here - The [Android sample app](/samples/android-toy-app) is a single-button activity that exercises this whole flow. - [Concepts: Proof of Location](/concepts/proof-of-location) explains what a verdict proves. - [Session-binding](/concepts/session-binding) ties a proof to a specific login, so your verifier can confirm the proof was made for that login. - [Verifying Proofs](/concepts/verifying-proofs) and the [Verifier Quick Start](/getting-started/verifier-quickstart) show how anyone can independently check the proofs your app produces. - [What's new in 1.2](/whats-new) lists the API added since 1.1. - [API Reference Overview](/api-reference/overview) maps the public surface. _Source: https://octetproof.com/docs/getting-started/android-quickstart/_ --- # Verifier Quick Start From source to a `VALID` verdict in a few minutes, using `octet-verify`, the open-source command-line tool that independently checks an Octet proof. :::note This page is the how-to. [Verifying Proofs](/concepts/verifying-proofs) explains what the verifier does and doesn't establish, and why it can run offline. ::: --- ## 1. Build it The verifier is public Rust. Clone and build: ```bash git clone https://github.com/octetproof/octet-verify cd octet-verify cargo build --release # binary at ./target/release/octet-verify ``` The default build is **offline and dependency-light**. It pulls no networking or JSON libraries, so the cryptographic path you're trusting stays small and auditable. Two optional features sit behind build flags: - `--features net` adds [backend fetch mode](#3-fetch-from-the-ingestion-api). - `--features appattest` adds offline [hardware-attestation](/concepts/device-attestation) validation: iOS App Attest to Apple's root, and the Android key-attestation chain to Google's root. Without it, the `attestation-root` check stays `NOT-CHECKED`. To check a session-bound proof (one made with a `sessionNonce`, see [Session-binding](/concepts/session-binding)), build `octet-verify` at 1.2.0 or later. An earlier build reports the session binding as `NOT-CHECKED` and still validates the rest of the proof. --- ## 2. Verify a proof you received If you have the proof bytes as a file (the SDK can export one, or a backend can hand you the raw bytes), that's all you need. There is no network, and no keys to trust beyond what is inside the proof: ```bash octet-verify proof.bin # or: cat proof.bin | octet-verify ``` A passing run looks like this: ``` == octet-verify == verdict: VALID [ PASS] freshness 42 s old (limit 300 s) [ PASS] nullifier replay token present (32 bytes) [ PASS] stage-chain 8 stages, hash linkage intact [ PASS] stage-signatures all 8 stage signatures verify [ PASS] chain-assembly final stage binds all 7 prior signatures [ PASS] field-binding commitment, nullifier, zkProof bound to signed stage hashes [ PASS] semantic-binding verdict / region / level / integrity / commitment bound [ PASS] region-claim claims country:US [NOT-CHECKED] attestation-root hardware key trusted as carried (rebuild --features appattest to validate) … 11 pass · 0 fail · 0 warn · 3 not-checked ``` The **exit code** is the automation-friendly signal: | Code | Meaning | |---|---| | `0` | **authentic**: valid and signatures cryptographically verified | | `1` | **invalid**: a check failed | | `2` | usage / IO / decode / backend error | | `3` | **inconclusive**: structurally valid but signatures not verified (e.g. no hardware key) | So a CI gate is just `octet-verify proof.bin && deploy`. Exit `0` means authentic, and an unverified proof exits `3`, never `0`. Add `--json` for one machine-readable object per proof (`valid` reports *authenticity*, plus `signatures_verified` and the `verdict` string): ```bash octet-verify proof.bin --json | jq 'select(.valid == false)' ``` Useful flags: `--hardware-pubkey ` (supply the device key for iOS proofs that carry no certificate), `--expect-region ` (assert a claimed region), and `--max-age-seconds ` (widen the freshness window). See the repo's [`INTEGRATION.md`](https://github.com/octetproof/octet-verify/blob/main/INTEGRATION.md) for the full reference. --- ## 3. Fetch from the ingestion API If your proofs are uploaded to the Octet proof ingestion API, the verifier can fetch and check them in one step. This needs the network-enabled build: ```bash cargo build --release --features net ``` The backend is **untrusted**. Fetched bytes run through the exact same checks as a local file. You just need a token to read your license's proofs. ### Getting an activation bearer `--token` is an **activation bearer**: a short-lived credential minted from your license key. The SDK obtains one automatically when it activates your license (`Octet.start`, see [License & Activation](/concepts/license-activation)). To fetch from a script or CI, mint one yourself against the public activation endpoint with your license key: ```bash export OCTET_TOKEN=$(curl -fsS https://api.octetproof.com/v1/activate \ -H "Content-Type: application/json" \ -d '{ "token": "", "device_fp": "verifier-cli", "os": "ios", "rt": "native" }' | jq -r '.bearer') ``` - **`token`**: your license key (from [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup)). - **`device_fp`**: any stable identifier for this caller (≤128 chars). - **`os`** ∈ `ios`/`android`/`windows`/`macos`/`linux`/`web` and **`rt`** ∈ `native`/`flutter`/`reactnative`/`web`. Set them to a platform your license covers. The response's `bearer` field is what you pass to `--token`. The CLI then exchanges it for a short-lived, read-scoped token internally. Re-run the activation if the bearer expires. ### Fetch and verify ```bash octet-verify fetch --backend https://api.octetproof.com --token "$OCTET_TOKEN" ``` Two more modes: - `octet-verify watch …`: poll for new proofs and verify each as it arrives (handy for a live demo or dashboard). - `octet-verify range --since --until …`: verify every proof in a window. Exits non-zero if any fails. > Proofs are stored for **less than 24 hours**, then purged. Fetch and verify > promptly. An aged-out id returns `404`. --- ## 4. The full loop, end to end Putting the pieces together, from device to independent verdict: ```mermaid flowchart LR A[Your app + OctetSDK
generate a proof] -->|upload| B[Ingestion API
stores under 24h, bytes only] B -->|fetch by id| C[octet-verify
checks signatures] C --> D[VALID / INVALID] ``` 1. **Produce.** Your app calls the SDK. On a `YES`/`NO` it gets a signed proof and uploads it. (Setup is in the [iOS](/getting-started/ios-quickstart) and [Android](/getting-started/android-quickstart) quick starts.) 2. **Store.** The ingestion API keeps the raw bytes for less than 24 hours, keyed by id. It never decodes or signs anything. 3. **Verify.** `octet-verify fetch …` pulls those bytes and validates them against the keys *inside* the proof. The verdict depends only on the bytes, not on anything the backend says. The result: a proof produced on a phone is confirmed by a separate, public binary, using only the bytes and the keys inside the proof. If the proof had been mutated, replaced, or fabricated anywhere along the way, the verifier rejects it. --- ## Troubleshooting | Symptom | Fix | |---|---| | `octet-verify: command not found` | Build it (step 1) and use `./target/release/octet-verify`, or put it on your `PATH`. | | `verdict: INCONCLUSIVE` | The signatures weren't checked, usually an iOS proof with no embedded certificate. Pass the device key with `--hardware-pubkey `. | | `verdict: INVALID` | A check failed. The output names which one. Common during development: a stale proof from before a wire change, or a key that isn't the one that signed the proof. | | `404` / no proof on fetch | The proof hasn't been uploaded yet, or it has aged past the retention window (less than 24 hours). Generate a fresh one and fetch within the day. | | `… requires a build with the net feature` | Rebuild with `cargo build --release --features net` to use `fetch` / `watch` / `range`. | ## Reference - [`README.md`](https://github.com/octetproof/octet-verify): overview and what the verifier does and does not check. - [`INTEGRATION.md`](https://github.com/octetproof/octet-verify/blob/main/INTEGRATION.md): full CLI reference, flags, and consumer guide. - [`VERIFICATION-SPEC.md`](https://github.com/octetproof/octet-verify/blob/main/VERIFICATION-SPEC.md): the byte-exact signing contract every check implements. _Source: https://octetproof.com/docs/getting-started/verifier-quickstart/_ --- # Proof of Location :::note[In one sentence] A *proof of location* is a signed, time-stamped record that a device was inside a region at a given time, and anyone holding it can verify the signatures offline. ::: ## Why a claim needs a proof A claim like "the device was in the US at 3 pm Tuesday" can come from the OS, the GPS chip, a browser API, or a mock-location app. All of them are easy to forge. The claim is only worth acting on if a third party (your backend, an auditor, a regulator) can check it without trusting whoever made it. The Octet SDK produces those proofs. Every `YES` or `NO` it returns carries one. An `INDETERMINATE` carries none. You can: - Forward a proof to your backend and verify it offline with the open-source verifier. - Hand it to a compliance auditor to re-check months later. - Store it as a durable record of where the device was at that time. ## How it works The SDK combines several independent on-device signals (GNSS, cellular network identity, motion sensors) with platform attestation (Apple App Attest, Google Play Integrity) to produce a verdict and a cryptographic proof. The signing key lives in the device's hardware root of trust (the Secure Enclave on iOS, the Trusted Execution Environment on Android) and never leaves the chip. The proof is a signed protobuf that names: - The **claimed region** (for example country `US`, or a disc of radius 250 m). - The **time of the fix** and the interval over which the proof stays valid. - A **confidence summary**: a score from 0 to 1 and a set of flags. - An **attestation chain** from platform hardware (App Attest on iOS, Play Integrity on Android), which the SDK checks when it builds the proof. You call `isWithin(region)` and get back a `Verdict`. On `YES` or `NO` the verdict carries a proof. If the SDK cannot get a trustworthy fix (rooted device, a mock-location app, or no usable GPS signal), it issues no proof and returns `INDETERMINATE` with a reason code. ```mermaid flowchart LR A[Your app] -->|isWithin region| B[OctetSDK] B --> C{Can the SDK
answer?} C -->|Yes| D[Verdict YES or NO
+ signed proof] C -->|No| E[Verdict INDETERMINATE
+ reason code] D -->|forward| F[Your backend] F -->|verify offline| G[octet-verify] ``` ## What the SDK does not do - **Flag users as spoofers.** It reports only whether a proof can be issued. Spoof signals fold into that decision. - **Guarantee a `YES`.** On an emulator, indoors with no cellular, on a rooted device, or with a mock-location app running, the result is `INDETERMINATE`. - **Predict the future.** A proof states where the device was at time T, not where it will be later. See [Time Semantics](/concepts/time-semantics). ## Where to go next - [Device Attestation](/concepts/device-attestation). How App Attest and Play Integrity tie a proof to genuine hardware. - [Verdicts](/concepts/verdicts). The `YES` / `NO` / `INDETERMINATE` values and how to read the reason code. - [Regions](/concepts/regions). What kinds of areas you can ask about. - [Time Semantics](/concepts/time-semantics). How `atTime` and proof validity intervals interact. _Source: https://octetproof.com/docs/concepts/proof-of-location/_ --- # Device Attestation :::note[In short] Every signed proof carries hardware-backed device attestation: Apple App Attest on iOS, Google Play Integrity on Android. A relying party can confirm the proof came from a genuine app on a genuine device, not an emulator or a script. No integration code is required. It is part of proof generation. ::: A [proof of location](/concepts/proof-of-location) answers where the device is. Device attestation answers a different question: is this a real app instance on real hardware? The two travel together. Each proof the SDK signs also carries an attestation produced by the operating system's own attestation service, so the [verifier](/concepts/verifying-proofs) can tie the proof to genuine hardware rather than take the device's word for it. This is separate from the [device-key security tier](/concepts/proof-of-location) (`HARDWARE_STRONGBOX` / `HARDWARE_TEE` / `SOFTWARE`), which records where the signing key lives. Attestation establishes that the app and OS are genuine. The security tier records the strength of the key that signed. A verifier reads both. Three uses of the word appear in these docs. **Platform attestation** is the service the operating system provides: Apple App Attest and Google Play Integrity. **Device attestation** is the check described on this page, which combines that service with a hardware-backed key. **Hardware attestation** is the offline validation of the platform roots, available when `octet-verify` is built with the `appattest` feature. ## What gets attested | Platform | Mechanism | What it shows | |---|---|---| | iOS | Apple App Attest | The proof came from your App ID, running on a genuine Apple device with a Secure Enclave key. | | Android | Google Play Integrity | The app binary, the device, and the Play install are recognized by Google. | | Android | Key attestation | The signing key lives in TEE or StrongBox, attested by a certificate chain rooted in Google's hardware-attestation root. | The evidence is bound into the signed proof chain. Editing it after signing breaks verification. On iOS the App Attest assertion carries an anti-replay counter, so a captured assertion cannot be reused on a later proof. ## Configuring how often it runs Attestation is on by default. How often a fresh attestation is produced is the one knob you set, on `OctetConfig.advanced.attestationCadence`: | Cadence | Behaviour | |---|---| | per-session | One attestation per SDK session. Lowest overhead. | | periodic (interval) | Re-attest on the interval you give. **Default** (5 minutes). | | per-proof | A fresh attestation on every proof. Highest assurance, highest cost. | ```swift let config = OctetConfig( licenseKey: "", advanced: AdvancedConfig( attestationCadence: .periodic(interval: 300) // the default ) ) let octet = try await Octet.start(config: config) ``` ```kotlin val config = OctetConfig( licenseKey = "", advanced = AdvancedConfig( attestationCadence = AttestationCadence.Periodic(intervalSeconds = 300), // Optional: bind Play Integrity to a specific Google Cloud project NUMBER // (not the project ID). Omit to use the project linked in the Play Console. playIntegrityCloudProjectNumber = null ) ) val octet = Octet.start(context, config) ``` **Android only:** verifying a Play Integrity token needs a Google Cloud project. By default the SDK uses the project linked to your app in the Play Console. To bind a specific one, set `playIntegrityCloudProjectNumber` to your Google Cloud project number. The number is a non-secret identifier. iOS App Attest has no equivalent setting. ## When attestation fails If the platform attestation service returns a verdict that is not compliant, the SDK does not sign a proof it cannot stand behind. The location query returns `indeterminate` with reason `attestationFailed`. Treat it as untrusted and do not retry blindly. See [Verdicts](/concepts/verdicts) for the full reason-code table. ## Verifying attestation off the device The on-device half is automatic. The off-device half lives in the verifier. [`octet-verify`](/concepts/verifying-proofs) validates attestation when built with the `appattest` feature: ```bash cargo build --release --features appattest ``` With that build: - **iOS App Attest** is validated offline against Apple's embedded App Attest root. No network call, no secret held. The same proof verifies identically anywhere. - **Android key attestation** validates the Keystore certificate chain to Google's embedded, fingerprint-pinned hardware-attestation root, confirming the key is TEE or StrongBox backed. Both paths are provided by [`octet-attest-verify`](https://github.com/octetproof/octet-attest-verify), a standalone library you can also use directly if you write your own verification logic. :::note[Play Integrity verification goes through Google] Verifying an **Android Play Integrity** token is not an offline operation, by Google's design: decoding and checking the token requires a round-trip to a Google Cloud project. `octet-attest-verify` does the proof-side half (it decodes the returned payload and confirms the token binds to the proof, by nonce and package). You supply the Google call with your own Cloud project. All three attestation paths are supported. The difference is only *where* the check runs: App Attest and Android key attestation verify fully offline against Apple's and Google's embedded roots. Play Integrity verifies through Google. ::: ## Establishing the hardware root ahead of time Added in 1.2. A verifier normally learns a device's hardware root from the attestation evidence that rides on that device's first proof. `Octet.attestationEnrolmentBundle()` returns that evidence directly, so a verifier can enrol the device's key before any proof arrives. This helps a verifier that was freshly deployed, scaled out, or migrated. On iOS the App Attest object is produced once per key, so pre-enrolment removes the wait for the first proof. On Android every proof already carries the full Key Attestation chain, so the bundle is a convenience mirror. See [`attestationEnrolmentBundle()`](/api-reference/octet-start). Two Android fixes in 1.2 affect verification: - The proof now carries the device-key security level (`device_attestation.security_level`) on the wire. A 1.1 serialization bug dropped it, so a verifier gating on a hardware tier could reject a healthy TEE or StrongBox device. A 1.2 proof reports the true tier. - The SDK regenerates the device key before its key-attestation chain ages out. The chain's intermediate is short-lived (about 14 days). The earlier build could present an aged-out chain about two weeks after enrolment, which fails verification. The key is now re-attested while the chain is still valid. ## Where to go next - [Verifying Proofs](/concepts/verifying-proofs): what the verifier checks, including the attestation-root step. - [Verdicts](/concepts/verdicts): the `attestationFailed` reason and the rest of the trichotomy. - [Proof of Location](/concepts/proof-of-location): the location half of the proof, and the device-key security tier. _Source: https://octetproof.com/docs/concepts/device-attestation/_ --- # Verifying Proofs :::note[In short] The proof is self-contained. With the open-source verifier and the bytes alone, anyone can confirm offline that a proof is signed by the key it carries and has not been altered since. The SDK establishes the location on the device, and the verifier proves that record is genuine. ::: A [proof of location](/concepts/proof-of-location) is only useful if a third party can verify it independently. `octet-verify` is the reference verifier: a standalone command-line tool whose source you can read, build, and audit at [github.com/octetproof/octet-verify](https://github.com/octetproof/octet-verify). It contains none of the SDK's proof-creation or spoof-detection logic, and confirms only that a proof is signed by the key it carries and has not been altered since. That narrow scope is what makes it safe to open-source. To run it, see the [Verifier Quick Start](/getting-started/verifier-quickstart). ## The trust model Three components, three levels of trust: ```mermaid flowchart LR A[Device + OctetSDK
produces & signs the proof] -->|uploads| B[Ingestion API
api.octetproof.com
stores bytes only] B -->|serves the same bytes| C[octet-verify
independent CLI] A -.->|or export the proof to a file| C C --> D{Verdict} ``` The ingestion API is optional. A proof can travel straight from the device to the verifier (the dashed path above). The API is a store-and-serve relay: it holds uploaded proof bytes by id for less than 24 hours so they can be fetched and verified later. Nothing about verifying a proof depends on it. | Component | Trusted to… | **Not** trusted to… | |---|---|---| | **OctetSDK** (on device) | produce a proof correctly, including determining the location correctly, and sign each stage with the hardware-backed key | vouch for its own signatures. The proof carries everything needed to re-check them, so you verify the signing yourself rather than take the SDK's word. | | **Ingestion API** (`api.octetproof.com`) | receive proof bytes, store them for less than 24 hours, and serve them back | decode, change, sign, or vouch for any proof. It is **transport and index only**. | | **octet-verify** | check signatures, chain linkage, and field bindings against the keys embedded in the proof | nothing. This is the one piece a recipient must trust, and its source is public. | The load-bearing property: **if the backend were fully compromised, the verifier would still reject a tampered or fabricated proof.** None of the verifier's checks rely on anything the backend says. They rely only on the bytes and the cryptographic keys inside the proof. A broken or hostile backend can only fail to return a proof. It cannot fabricate a proof that the verifier will accept. ## What the verifier checks Against the bytes of a single proof, the default build of `octet-verify` confirms: - **Stage signatures.** Every stage of the proof is signed by the same hardware-backed P-256 key the proof carries. - **Chain linkage.** Each stage links to the one before it. The final assembly stage binds every prior signature into a single value. Change any stage and the chain breaks. - **Field bindings.** The commitment, the nullifier, and the ZK bytes match the hashes they were signed under. A field present with no signed binding fails, so nothing rides along unverified. - **Semantic-field binding.** The spoofing verdict, the region, the level, the integrity status, and the position commitment are bound to the signed proof. Editing any of them after signing is rejected. - **Session binding.** When the proof was made with a `sessionNonce`, the verifier confirms a hash of that nonce is committed and bound into the signed proof. Your login backend then compares that hash against the nonce it issued. A verifier below 1.2.0 reports this as `NOT-CHECKED`. See [Session-binding](/concepts/session-binding). - **Freshness.** The proof falls within the age window you allow, judged against the signed timestamp rather than the editable top-level field. - **Wire-format guard.** A proof smuggling a duplicate of a single-value field is rejected. Two more checks run when you pass the upload envelope (`--envelope`): - **Transport signature.** An Ed25519 signature ties the whole proof to the enrolled device identity. - **Replay-control binding.** The backend-supplied upload nonce, nullifier, and signed timestamp are confirmed against what the proof itself signed. Semantic-field binding and replay-control binding are new in the v1.1 verifier. Session binding is new in the 1.2 verifier. The exact byte layout of every signature is documented in the repo's [`VERIFICATION-SPEC.md`](https://github.com/octetproof/octet-verify/blob/main/VERIFICATION-SPEC.md). ## What a verdict means The verifier's verdict is not the same as the [SDK's verdict](/concepts/verdicts). Keep them separate: - The **SDK** answers *"is the device in the region?"* It returns `YES`, `NO`, or `INDETERMINATE`. - The **verifier** answers *"do this proof's signatures verify, and is it unaltered?"* It returns one of three states: | Verdict | Meaning | |---|---| | **VALID** | The proof passed every check and its signatures verified cryptographically. | | **INCONCLUSIVE** | The proof is structurally fine, but its signatures could not be checked (for example, the hardware public key was not available). This is **not** a pass. Assurance was not established. | | **INVALID** | A check failed. The proof is rejected. | Each individual check is reported as `PASS`, `FAIL`, or `NOT-CHECKED`. A `NOT-CHECKED` line never fails a proof and never makes it valid. It is shown so the boundary of what was confirmed is explicit and never overstated. ### Hardware attestation: the `appattest` build The default build trusts the signing key as carried: a passing proof shows the proof is signed by that key and unaltered, not that the key is genuine device hardware. To close that gap, build with the `appattest` feature: ```bash cargo build --release --features appattest ``` That build validates the [device attestation](/concepts/device-attestation) offline: - **iOS App Attest** verified against Apple's embedded App Attest root. - **Android key attestation** validated up the Keystore certificate chain to Google's embedded hardware-attestation root, confirming a TEE or StrongBox key. These two offline checks run through [`octet-attest-verify`](https://github.com/octetproof/octet-attest-verify), and the `attestation-root` line in the output moves from `NOT-CHECKED` to a real `PASS` or `FAIL`. The default build keeps it `NOT-CHECKED` so the boundary of what was confirmed stays explicit. The Android Play Integrity token is a separate signal that verifies through Google (a Cloud-project round-trip), not offline. See [Device Attestation](/concepts/device-attestation) for the full picture. ## Where to go next - [Verifier Quick Start](/getting-started/verifier-quickstart): build it and verify your first proof. - [Device Attestation](/concepts/device-attestation): what attestation proves, and the `appattest` verification path. - [Proof of Location](/concepts/proof-of-location): what a proof is and why it can be checked. - [Verdicts](/concepts/verdicts): the SDK's `YES`, `NO`, and `INDETERMINATE`, which answer a different question. _Source: https://octetproof.com/docs/concepts/verifying-proofs/_ --- # Session-binding :::note[In short] Pass a one-time `sessionNonce` to any predicate. The SDK commits a hash of it inside the signed proof, so your verifier can confirm the proof was made for one specific login rather than replayed from another. ::: ## The problem it solves A [proof of location](/concepts/proof-of-location) says a device was inside a region at a time. On its own it does not say which request it was made for. An attacker who captures a valid proof from a device could forward it to authorize a different login from that device, or replay it later. For a login or a step-up check, you want the proof bound to the exact session you are authorizing. ## How it works 1. Your login backend issues a one-time nonce for this login attempt and sends it to the app. 2. The app passes it as `sessionNonce` on a predicate call: `isWithin`, `isOutside`, or `contains`. 3. The SDK hashes the nonce and commits the hash inside the signed proof. Only the hash is serialized. The raw nonce never rides in the proof. 4. The app forwards `verdict.proof` to your backend. 5. Your backend verifies the proof with `octet-verify` 1.2.0 or later, then compares the committed hash against the nonce it issued. A match ties the proof to that login. The hash sits inside the signed proof, so editing it after signing breaks verification. A proof made for one nonce does not verify as a proof for another. The nonce is opaque bytes, 1 to 512 of them, so it can be a raw login nonce, a hashed token, or any per-request challenge. An empty nonce, or one over 512 bytes, returns an `INDETERMINATE` verdict with reason `INVALID_SESSION_NONCE` and no proof. A session-bound call always generates a fresh proof rather than serving one from cache, so each call reflects the device's location at that moment. ```swift let nonce = try await loginBackend.issueNonce() // one-time login nonce let verdict = await sdk.loc.isWithin( region: .country(isoCode: "US"), sessionNonce: nonce ) if verdict.result == .yes { send(verdict.proof!, to: loginBackend) // backend re-checks the nonce hash } ``` ```kotlin val nonce: ByteArray = loginBackend.issueNonce() // one-time login nonce val verdict = sdk.loc.isWithin( region = OctetRegion.country("US"), sessionNonce = nonce, ) if (verdict.result == OctetVerdict.Result.YES) { send(verdict.proof!!, loginBackend) // backend re-checks the nonce hash } ``` ## What the verifier does `octet-verify` 1.2.0 or later confirms the proof commits a session-nonce hash and that the commitment is bound into the signed proof. A verifier below 1.2.0 does not recognize the stage and reports it as `NOT-CHECKED`, validating the rest of the proof. Comparing the committed hash against the nonce you issued is your backend's step, because only your backend knows which nonce it sent. ## When to use it - **Login and step-up checks.** Bind the location proof to the specific login you are authorizing. - **Wire-approval and other high-value actions.** Bind the proof to the action's server-issued token. Omitting `sessionNonce` produces the same proof as 1.1. An integration that does not pass it is unchanged. ## Availability Session-binding is available from SDK 1.2.0. Enforcing it needs `octet-verify` 1.2.0 or later. The proof wire format stays a strict superset of 1.1: a proof made without a nonce is byte-identical to a 1.1 proof. ## Where to go next - [Predicates](/api-reference/predicates). The `sessionNonce` parameter on each predicate. - [Verifying Proofs](/concepts/verifying-proofs). The full list of checks, including session binding. - [Serialization](/api-reference/serialization). What the proof commits and serializes. _Source: https://octetproof.com/docs/concepts/session-binding/_ --- # Verdicts :::note[In one sentence] A verdict is one of three values, `YES`, `NO`, or `INDETERMINATE`, where the third lets the SDK report that it cannot answer instead of falsely returning `NO`. ::: ## Why not a boolean A boolean would collapse two different outcomes: the device was not in the region, and the SDK could not tell. The SDK cannot tell for ten distinct reasons: - It has started but has not seen a usable fix yet. - The query is about a moment in the future. - The query is about a moment too old to have a cached proof. - The cached proof is too coarse for the question (for example, a country-level proof cannot answer a city-level question). - Conditions cannot support a proof at the precision you asked for. - The OS flagged the location as mocked. - App Attest or Play Integrity returned a non-compliant verdict. - The session ended. - The predicate path is not wired in this SDK build yet. - The supplied session nonce was empty or larger than 512 bytes. Collapsing these into `NO` would hide the difference between *answered no* and *could not answer*. Code that retries on failure, or forwards a `NO` as if it were a proof, would then do the wrong thing. The three values keep the two cases separate. ## The three values ```mermaid flowchart TD Q[Predicate query] --> E{Can the SDK
evaluate it?} E -->|Yes, condition holds| YES[YES + proof] E -->|Yes, condition does not hold| NO[NO + proof] E -->|No| IND[INDETERMINATE
+ ReasonCode] ``` - **`YES`**. The predicate holds, and a proof is attached. - **`NO`**. The predicate does *not* hold, and a proof of the negative is attached. `NO` does not mean "I don't know." - **`INDETERMINATE`**. The SDK cannot answer. The `reason` field says why, and the `proof` is `nil`. ## Reason codes | `ReasonCode` | When | Result | |---|---|---| | `OK` | Proof covers `atTime`. Predicate evaluated cleanly. | `YES` or `NO` | | `NO_FIX` | SDK started but no fix yet. | `INDETERMINATE` | | `FUTURE_TIME` | `atTime` is in the future beyond ±2 s clock-skew tolerance. | `INDETERMINATE` | | `STALE_FIX` | `atTime` falls outside the validity window of any cached proof. | `INDETERMINATE` | | `NO_PROOF_AT_RESOLUTION` | Cached proof is too coarse for the query (for example, country-level proof vs. city-level question). | `INDETERMINATE` | | `INSUFFICIENT_PRECISION` | Conditions cannot support a proof at the requested precision. `achievableLevel` names the best level reachable. | `INDETERMINATE` | | `ATTESTATION_FAILED` | Play Integrity / App Attest verdict not COMPLIANT. | `INDETERMINATE` | | `MOCK_LOCATION_DETECTED` | OS flagged mocked location for this fix. | `INDETERMINATE` | | `SDK_NOT_RUNNING` | `Octet.start(...)` was never called or session ended. | `INDETERMINATE` | | `NOT_YET_RELEASED` | Predicate path declared in the API but not wired in this SDK build. | `INDETERMINATE` | | `INVALID_SESSION_NONCE` | Supplied `sessionNonce` is empty or exceeds 512 bytes. Added in 1.2. | `INDETERMINATE` | The `reason` code names the category that blocked the proof (for example `ATTESTATION_FAILED` or `MOCK_LOCATION_DETECTED`). What the SDK does not surface is the sensor-level detail beneath it: which signal fired, or how. Exposing that is a security risk: it tells an attacker exactly what to defeat. The emulator / simulator hint is a separate developer-environment indicator, not a security one. ## Achievable level When the result is `INDETERMINATE` with reason `INSUFFICIENT_PRECISION`, the verdict carries an `achievableLevel`: the best precision the SDK could actually reach right now. The SDK does not down-level silently on your behalf. It tells you what it can prove and lets you decide, rather than quietly answering a coarser question than you asked. Read it to choose between re-requesting at the coarser level or applying your own fallback: ```swift // iOS if verdict.reason == .insufficientPrecision, let level = verdict.achievableLevel { // e.g. you asked for city, the SDK can prove country right now. // Re-request at `level`, or treat as not-good-enough for your use case. } ``` ```kotlin // Android if (verdict.reason == VerdictReason.INSUFFICIENT_PRECISION) { val level = verdict.achievableLevel // best level reachable now, or null } ``` ## Strict-boolean callers If your logic needs a boolean, collapse the verdict yourself, and make the threshold explicit: ```swift // iOS let ok = verdict.result == .yes && verdict.confidence.overallScore > 0.8 ``` ```kotlin // Android val ok = verdict.result == OctetVerdict.Result.YES && verdict.confidence.overallScore > 0.8 ``` A low-confidence `YES` is still a valid proof. Some domains (KYC, sanctions, payments) need a tighter bar. The SDK does not set that bar. The confidence summary is on the verdict, so you can set that bar yourself. ## Where to go next - [Time Semantics](/concepts/time-semantics) for how `atTime`, `validity`, and the staleness window interact. - [Regions](/concepts/regions) for why `NO_PROOF_AT_RESOLUTION` can fire even when you "have" a proof. - [OctetVerdict API Reference](/api-reference/octet-verdict) for the exact field types. _Source: https://octetproof.com/docs/concepts/verdicts/_ --- # Regions :::note[In one sentence] An `OctetRegion` is the *area* you ask about (country, city, disc, polygon, bounding box), and its shape determines how the SDK proves containment. ::: ## How shapes map to evidence Different region shapes are verified by different evidence. A *country* is best proven via the device's mobile country code. The SIM and the serving cell tower both name a country. A *disc* needs a real GPS fix and can fall back to inertial signals. A *city* resolves to a polygon set. Letting callers supply a free-form lat/lon polygon would force the SDK to triangulate and re-quantize every query, which the H3-based proof format does not support. Polygons are therefore accepted only as **H3 cell sets**. ```mermaid flowchart TD R[OctetRegion] --> Named[Named lookups] R --> Geometric[Geometric shapes] Named --> Country[country isoCode] Named --> Sub[subdivision isoCode] Named --> City[city name] Geometric --> Disc[disc center radius] Geometric --> Ellipse[ellipse center axes heading] Geometric --> Box[box3D lat lon alt ranges] Geometric --> Poly[polygonSet H3 cells] Geometric --> Earth[earth maxAltitude] ``` ## The shapes | Factory | Verified via | Typical use | |---|---|---| | `earth(maxAltitudeMeters)` | Always `YES`. | Sanity check / fallback. | | `country(isoCode)` | Fused MCC from serving cell, network operator, SIM home country. | "Is the user in the US?" Works indoors, often without GPS. | | `subdivision(isoCode)` | Reverse-geocoded admin area on a trusted fix, with a multi-signal estimator as fallback. | ISO 3166-2 codes: `US-CA`, `FR-75`, `JP-13`. | | `usState(stateCode)` | Same as `subdivision`. | Sugar for `subdivision("US-XX")`. | | `city(name)` | SDK resolves the name to an H3 polygon set. The proof is verified against that polygon. | "Is the user in San Francisco?" | | `disc(center, radiusMeters)` | H3-rasterized inside the proof, analytic on the caller side. | "Is the device within 250 m of a saved anchor point?" | | `ellipse(center, semiMajorM, semiMinorM, headingDeg)` | 2D ground ellipse. Disc is a special case. | GNSS uncertainty ellipses, oriented zones. | | `box3D(latRange, lonRange, altRange)` | 3D bounding box. | Volumetric containment, building floors. | | `polygonSet(cells)` | Union of H3 cells. | Custom geofences pre-quantized to H3. | All factories **validate eagerly**. Invalid lat/lon, malformed ISO codes, non-positive radii, ellipse axes out of order: all trap at construction. An invalid region never reaches a predicate call. ## Why polygons are H3-only If you have a free-form polygon (a delivery zone, an event venue, a building footprint), you (or your tooling) quantize it to H3 cells ahead of time and pass the cell list. The SDK uses those cells directly in the proof's Merkle membership circuit, so nothing is triangulated at request time and the geometry a caller passes never reaches the proof itself. The H3 cell ID encodes its own resolution. The SDK picks the *coarsest* resolution per query that still satisfies the predicate's accuracy needs. Fine cells when you ask about a city block, coarse cells when you ask about a city. ## Construction helpers (Android only) On Android, two additional surfaces let you build regions dynamically: - `getRegion(RegionSpec.country("US"))`. Symbolic lookup, useful when the region name comes from configuration or a server response. The `city` / `namedZone` variants currently throw `OctetFutureFlag` (atlas + server lookup is not yet wired). - `buildRegion { disc(center = LatLon(37.42, -122.08), radiusMeters = 250.0) }`. A DSL over the static factories for assembling regions from runtime data. iOS exposes the static factories only at v1. Platform parity for the DSL and `RegionSpec` is on the roadmap. ## Inspecting a region Two helpers turn a region into a string: - **`whatisRegion(r)`**. Short, human-readable. For log lines and debug overlays. **Not stable** across SDK versions. - **`regionToStr(r)`**. Canonical, machine-readable, **stable** wire form. Suitable for log diffs, idempotency keys, eventual `regionFromStr` round-trip. Both are surfaced uniformly on every API object via `.toStr()`, `.toJson()`, and `.toJsonl()`. See [Serialization](/api-reference/serialization). ## Where to go next - [OctetRegion API Reference](/api-reference/octet-region) for the exact signatures and validation rules. - [Verdicts](/concepts/verdicts) for what happens when the cached proof's region is coarser than your query. _Source: https://octetproof.com/docs/concepts/regions/_ --- # Time Semantics :::note[In one sentence] Every predicate takes an `atTime`, and every verdict carries the *interval* over which its proof answers the question. A proof attests to a moment in time, not an ongoing state. ::: ## The `atTime` parameter Most location SDKs return "where you are now." The Octet SDK lets you ask "where were you at this *specific* moment". The answer a verifier cares about is the moment of a business event: when the order was placed, when the auth attempt happened, when the camera was triggered. `atTime` defaults to "now", so most calls look like: ```swift let v = await sdk.loc.isWithin(region: .country(isoCode: "US"), atTime: Date()) ``` You can also pass any past instant within the SDK's cached-proof window. The SDK will search for a proof whose validity covers it. ## How `atTime` is resolved ```mermaid flowchart LR A[atTime in the future?] -->|yes, >2s| F[INDETERMINATE / FUTURE_TIME] A -->|no| B[atTime near now?] B -->|±2s| L[Live query: use ring buffer
or trigger a fresh proof] B -->|older| H[Historical query: search
cached proofs for a covering one] L --> R[Result] H -->|covered| R H -->|not covered| S[INDETERMINATE / STALE_FIX] ``` 1. **Future**. `atTime > now + 2 s` returns `INDETERMINATE / FUTURE_TIME`. The SDK does not predict. 2. **Live**. Within ±2 s of `now`. The SDK searches its ring buffer at the right resolution. On a miss, it can trigger a fresh proof. 3. **Historical**. Older. The SDK searches cached proofs for one whose `[from, to]` interval covers `atTime`. On a miss, `INDETERMINATE / STALE_FIX`. The 2 s tolerance is the SDK's internal clock-skew budget. It is not configurable in v1. ## Validity intervals A `LocationProof` is good for a window around its fix time, not forever. The SDK's per-resolution windows reflect how quickly a moving user can leave the claimed region: | Disclosure level | Window (± from fix) | Reasoning | |---|---|---| | `country` | 5 min | A commercial flight is the fastest realistic country-change vector. | | `subdivision` | 3 min | Margin against transitions across small US states. | | `city` | 1 min | A 5 km city radius is crossed in ~3 min at 100 km/h. | | `continuousArea` / `continuousVolume` | 30 s | Hundred-meter scales. A sprinter exits in seconds. | The returned `validity` field on every `YES` / `NO` verdict tells you *exactly* what window applies to *this* proof. Do not assume a flat 30 s for everything. If your business rule needs a tighter time bound than `validity` provides, reject the verdict. :::caution **`validity` is not "how long the verdict will keep being true."** It is the interval the underlying proof covers, not a promise about the future. ::: ## Validity in practice The SDK reports the interval its proof answers, not the moment of the predicate call. That distinction matters when: - A user asks "where was I at 3:00:00 pm" and the proof was fixed at 3:00:30 pm. With a 60 s window, that proof covers 2:59:30 to 3:01:30. So `atTime = 3:00:00` is answered with `validity = [2:59:30, 3:01:30]`. - A 5-minute country-level proof can answer a *flurry* of country-level predicate calls without regenerating. The verifier sees one proof. The integrator sees the same `validity` repeated across the verdicts. ## Where to go next - [Verdicts](/concepts/verdicts) for the full `ReasonCode` taxonomy including `STALE_FIX` and `FUTURE_TIME`. - [Predicates API Reference](/api-reference/predicates) for the exact signatures. - [OctetVerdict API Reference](/api-reference/octet-verdict) for the `validity` field type. _Source: https://octetproof.com/docs/concepts/time-semantics/_ --- # License & Activation :::note[In one sentence] A license key unlocks the SDK. It is verified on the first call to `Octet.start(...)` and the SDK works from a local cache afterwards. ::: ## Getting a license License keys are issued via [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup). Paste the key into `OctetConfig.licenseKey` and call `Octet.start(...)`. Nothing else is required. Use of the SDK is governed by the [Mobile SDK terms](https://octetproof.com/terms/mobile/). The current terms live at `/terms/mobile/`; a dated snapshot lives at `/terms/mobile/vMM.DD.YYYY/`. The first call activates the license over the network. Subsequent launches read from a local cache. The SDK does not contact the activation backend again until the license needs to renew. As of 1.2, when a lease response returns a refreshed license token, the SDK persists it. The device then carries a fresh token across restarts throughout the offline-grace window, rather than falling back to the token it first activated with. ## Validity A key authorises your app. It does not meter it: one key covers every device you install on, and billing is counted separately, in monthly active users. License keys renew automatically. The first **1,000 active users each month are free**. Past that, usage is billed at **one US cent per active user, per month**, whichever Octet product you use. An active user is a device that asked your app for at least one proof that month. The SDK reports a count per device, per month, and nothing else: no identity, no coordinates. A device that proves a thousand times counts once. Verifying costs nothing: it happens in your own code, offline. You should not need to replace a working key. Install on as many devices as you like. There is no per-license device cap. ## License states Octet maintains active licenses, so a key in normal use stays in `ACTIVE`. The `GRACE_PERIOD` and `EXPIRED` states below handle prolonged offline periods and revocation -- a maintained key is not turned off for getting old. ```mermaid stateDiagram-v2 [*] --> NOT_ACTIVATED: license verified locally NOT_ACTIVATED --> ACTIVE: activation succeeds ACTIVE --> RENEWAL_RECOMMENDED: < 30 days to expiry ACTIVE --> GRACE_PERIOD: cached past exp, offline RENEWAL_RECOMMENDED --> GRACE_PERIOD: same trigger GRACE_PERIOD --> EXPIRED: past grace period NOT_ACTIVATED --> INVALID: signature / server reject ACTIVE --> INVALID: server reject EXPIRED --> [*] INVALID --> [*] ``` Read the state at any time: ```swift // iOS if let status = sdk.licenseStatus { print("state: \(status.state), days left: \(status.daysUntilHardStop ?? -1)") } ``` ```kotlin // Android sdk.licenseStatus?.let { status -> println("state: ${status.state}, days left: ${status.daysUntilHardStop ?: -1}") } ``` `LicenseStatus.state` drives in-app UI only. It never drives the cryptographic gate. Forcing `state = ACTIVE` in your own code accomplishes nothing. ## Failure modes `Octet.start(...)` throws a typed `LicenseError`. The complete taxonomy is in [License Types](/api-reference/license-types). The common cases: - `MalformedKey`. The key isn't a valid signed token. Fix: re-copy the key. - `NoActivation`. First launch, and the device is offline. Fix: retry when network returns. - `Expired`. Cached activation past the offline grace, backend unreachable. Fix: reconnect so the SDK can re-activate. - `ActivationWindowClosed`. Not returned under the current maintained-license model. Retained for compatibility. Fix: contact support. - `Revoked`. Admin revoke (key leaked, abuse). Fix: contact support. - `Network(message:)`. Transient. Fix: retry. - `ServerRejected(httpStatus:, reason:)`. Anything else from the backend. Fix: see `reason`. ## Usage telemetry The SDK reports **aggregate, privacy-preserving usage counters** to the license backend, indexed by your license: how many proofs were generated, uploaded, or could not be produced, by coarse level and region type. The counters carry **no location data**: no coordinates, no region IDs, no proof contents. They are buffered in an encrypted file in the app's private storage and uploaded at most once a day. It is on by default. Disable it with `telemetryEnabled = false` on [`OctetConfig`](/api-reference/octet-start). Disabling deletes any buffered file. ## SDK version gating Added in 1.2. The SDK reports its version and platform on every backend request. The backend can act on that in two ways: - A hard gate. `Octet.start(...)` throws `LicenseError.upgradeRequired(minVersion:message:)` when the backend rejects an out-of-support version. - A soft hint. `LicenseStatus.upgradeRecommended` and `minSupportedVersion` flag a version behind the recommended floor without stopping the SDK. Both paths are inert in 1.2, because the backend gates no version yet. `upgradeRequired` is not thrown, and `upgradeRecommended` stays `false`. See [License Types](/api-reference/license-types) for the field and case shapes. ## Where to go next - [Prerequisites](/getting-started/prerequisites) for how to request a license key. - [License Types API Reference](/api-reference/license-types) for the exact types of `LicenseStatus` and `LicenseError`. - [Troubleshooting FAQ](/troubleshooting/faq) for what to do when a key fails. _Source: https://octetproof.com/docs/concepts/license-activation/_ --- # API Reference Overview The public surface is small and shaped around one verb: ask the SDK a yes/no question about location, get back a `Verdict` with a signed proof. ## The full surface ```mermaid flowchart TD A[Octet.start config] --> B[OctetSdk handle] B --> L[OctetSdk.loc] B --> S[OctetSdk.licenseStatus] L --> P1[isWithin region atTime] L --> P2[isOutside region atTime] L --> P3[contains center tol atTime] P1 --> V[OctetVerdict] P2 --> V P3 --> V V --> R[result YES NO INDETERMINATE] V --> RC[reason ReasonCode] V --> PR[proof LocationProof?] V --> VAL[validity Interval?] V --> C[confidence ConfidenceSummary] ``` ## Per-symbol reference | Symbol | What it is | |---|---| | [`Octet.start(...)`](/api-reference/octet-start) | The single bring-up call. Verifies license, activates, returns an `OctetSdk`. Also exposes `attestationEnrolmentBundle()`. | | [`OctetSdk`](/api-reference/octet-sdk) | The handle returned by `start`. Exposes `loc` and `licenseStatus`. | | [Predicates](/api-reference/predicates) | `isWithin`, `isOutside`, `contains` on `sdk.loc`, each with an optional `sessionNonce`. | | [`OctetRegion`](/api-reference/octet-region) | The shape you query against. Factories: `country`, `subdivision`, `usState`, `city`, `disc`, `ellipse`, `box3D`, `polygonSet`, `earth`. | | [`OctetVerdict`](/api-reference/octet-verdict) | The answer. Carries `result`, `reason`, `proof`, `validity`, `confidence`. | | [License Types](/api-reference/license-types) | `LicenseStatus`, `LicenseState`, `LicenseError`. | | [Serialization](/api-reference/serialization) | `.toStr()`, `.toJson()`, `.toJsonl()` on every public type. | ## Platform asymmetry notes (v1) | Surface | iOS | Android | |---|---|---| | `OctetRegion` factories | All | All | | `RegionSpec` + `getRegion(spec)` | n/a | ✓ | | `buildRegion { … }` DSL | n/a | ✓ | | `whatisRegion(r)` / `regionToStr(r)` free functions | (via `.toStr()` extension) | ✓ (also `.toStr()`) | Closing the gap is on the roadmap. iOS callers use the factories directly and `region.toStr()` for the human-readable form. ## Conventions used throughout - **Code samples are shown in tabs**, Swift on the left, Kotlin on the right. Pick your platform once. The choice is remembered across the site. - **Async**. `Octet.start` is `async throws` (Swift) / `suspend` (Kotlin). The predicates (`isWithin`, etc.) are `async` and do *not* throw. License or runtime problems become `INDETERMINATE` reasons on the verdict. - **Validation is eager.** Region factories trap on malformed inputs at construction. The predicate API never sees an invalid region. - **Time is `Date` on iOS, `java.time.Instant` on Android.** Both default to "now". - **`Meters` is a `Double` typealias** on both platforms (a plain `Double`, not a wrapper type). - **`LatLon(latitude:, longitude:)`** is the coordinate type. WGS84, pre-validated. ## Where to start If you're integrating for the first time, go to the [iOS Quick Start](/getting-started/ios-quickstart) or [Android Quick Start](/getting-started/android-quickstart). They exercise every symbol on this page end-to-end. _Source: https://octetproof.com/docs/api-reference/overview/_ --- # `Octet.start(...)` The single bring-up call. Verifies the license key locally, activates against the Octet backend if needed, brings up the proof pipeline, and returns a fully-usable `OctetSdk` handle. ## Signature ```swift public enum Octet { public static let sdkVersion: String // "1.2.1" public static func start( config: OctetConfig, startPosition: Position? = nil ) async throws -> OctetSdk public static func attestationEnrolmentBundle() -> AttestationEnrolmentBundle? } ``` ```kotlin object Octet { const val SDK_VERSION: String // "1.2.1" suspend fun start( context: Context, config: OctetConfig, startPosition: Position? = null, ): OctetSdk fun attestationEnrolmentBundle(): AttestationEnrolmentBundle? } ``` `startPosition` is an optional hint used internally during pipeline bring-up. Most integrators omit it. ## What it does, in order 1. Loads (or generates) a per-install UUID from secure storage (Keychain on iOS, `EncryptedSharedPreferences` on Android). 2. Auto-detects the app id from the bundle (`Bundle.main.bundleIdentifier` / `context.packageName`). 3. Verifies the license key's PASETO signature locally against the SDK's embedded public keys. 4. Validates the cached activation token if present. Otherwise calls `POST /v1/activate` to acquire one. 5. Brings up the internal proof pipeline. 6. Attaches the resulting `LicenseStatus` to the returned `OctetSdk`. ## `OctetConfig` ```swift public struct OctetConfig: Sendable { public let licenseKey: String public var proofUploadUrl: String? // opt-in proof upload; nil disables (default) public var telemetryEnabled: Bool // aggregate usage counters; default true public var advanced: AdvancedConfig public init( licenseKey: String, proofUploadUrl: String? = nil, telemetryEnabled: Bool = true, advanced: AdvancedConfig = AdvancedConfig() ) public static let defaultActivationServerUrl: String // "https://api.octetproof.com" } ``` ```kotlin data class OctetConfig( val licenseKey: String, val proofUploadUrl: String? = null, // opt-in proof upload; null disables (default) val telemetryEnabled: Boolean = true, // aggregate usage counters; default true val advanced: AdvancedConfig = AdvancedConfig(), ) { companion object { const val DEFAULT_ACTIVATION_SERVER_URL: String // "https://api.octetproof.com" } } ``` `licenseKey` is the only required field. It is a PASETO v4.public token in the wire form `octet_live_v4.public.…` (prod) or `octet_test_…` (staging). ## `AdvancedConfig` Still small. Battery profile, sensor tuning, and ML knobs stay internal. What is public: the activation server, log level, [device-attestation](/concepts/device-attestation) cadence, and the per-platform attestation knobs. ```swift public struct AdvancedConfig: Sendable { public var activationServerUrl: String // default: production public var logLevel: LogLevel // default: .info public var enableCertPinning: Bool // default: false public var attestationCadence: AttestationCadence // default: .periodic(interval: 300) public init( activationServerUrl: String = OctetConfig.defaultActivationServerUrl, logLevel: LogLevel = .info, enableCertPinning: Bool = false, attestationCadence: AttestationCadence = .periodic(interval: 300) ) } public enum LogLevel { case verbose, debug, info, warn, error } public enum AttestationCadence: Sendable { case perSession case periodic(interval: TimeInterval) // default; 5 minutes case perProof } ``` ```kotlin data class AdvancedConfig( val activationServerUrl: String = OctetConfig.DEFAULT_ACTIVATION_SERVER_URL, val logLevel: LogLevel = LogLevel.INFO, // Google Cloud project NUMBER for Play Integrity; null uses the Play-Console-linked project. val playIntegrityCloudProjectNumber: Long? = null, val attestationCadence: AttestationCadence = AttestationCadence.Periodic(intervalSeconds = 300), ) enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR } sealed class AttestationCadence { object PerSession : AttestationCadence() data class Periodic(val intervalSeconds: Int) : AttestationCadence() // default; 5 minutes object PerProof : AttestationCadence() } ``` Override `activationServerUrl` only when pointing at a staging or local backend. `attestationCadence` and the Android `playIntegrityCloudProjectNumber` are covered in [Device Attestation](/concepts/device-attestation). `enableCertPinning` opts into the bundled certificate pin set for `api.octetproof.com`. The Play Integrity cloud-project knob is Android-only: App Attest on iOS has no equivalent. ## `attestationEnrolmentBundle()` Added in 1.2. Returns this device key's `AttestationEnrolmentBundle`, or `nil` before the device key has been attested. Attestation first happens on the install's first proof. The read is local (a Keychain entry on iOS, a Keystore entry on Android) and makes no network call. Hand the bundle to a verifier's enrolment step so the verifier can establish this device's hardware root ahead of time, without waiting for the once-per-key attestation object to arrive on a submitted proof. This helps a verifier that was freshly deployed, scaled out, or migrated. ```swift public struct AttestationEnrolmentBundle: Sendable { public func jsonString() -> String // canonical v:1 envelope public func protoData() throws -> Data // DeviceAttestation proto bytes } ``` ```kotlin class AttestationEnrolmentBundle internal constructor(/* … */) { fun jsonString(): String fun protoData(): ByteArray } ``` On iOS, App Attest produces the object once per key, so a verifier that has not yet seen a proof from this device cannot check its hardware root until the bundle arrives. On Android, every proof already carries the full Key Attestation certificate chain, so the bundle is a convenience mirror rather than a requirement. ## Example ```swift let config = OctetConfig( licenseKey: "octet_live_v4.public.…" // advanced left to defaults ) let sdk = try await Octet.start(config: config) ``` ```kotlin lifecycleScope.launch { val sdk = Octet.start( context = applicationContext, config = OctetConfig(licenseKey = "octet_live_v4.public.…") ) } ``` ## Failure modes `Octet.start(...)` throws a typed [`LicenseError`](/api-reference/license-types) for every license-related failure. Other failures propagate as their native error types. The SDK does **not** throw a raw `Error` / `Exception` for license reasons. | `LicenseError` case | Meaning | |---|---| | `MalformedKey` | The key isn't a valid signed token. | | `NoActivation` | No cached activation, offline. | | `Expired` | Cached activation past the offline grace with the backend unreachable to refresh. | | `ActivationWindowClosed` | Fresh device trying to activate after day 90. | | `Revoked` | Admin revoke. | | `Network(message)` / `Network(cause)` | Transient network failure during activation. | | `ServerRejected(httpStatus, reason)` | Backend rejected for another reason (e.g., `app_blocked`). | | `UpgradeRequired(minVersion, message)` | Backend rejected this SDK version as out of support. | `UpgradeRequired` is part of SDK-version upgrade gating, added in 1.2. The SDK reports its version and platform on every backend request, so the backend can refuse an out-of-support version at `Octet.start`, or return the softer `LicenseStatus.upgradeRecommended` and `minSupportedVersion` hints instead. Both paths are inert in 1.2, because the backend gates no version yet. See [License Types](/api-reference/license-types) for the field and case shapes. ## See also - [License & Activation](/concepts/license-activation) for the timeline model and activation flow. - [`OctetSdk`](/api-reference/octet-sdk) for what `start` returns. - [License Types](/api-reference/license-types) for the full `LicenseError` reference. _Source: https://octetproof.com/docs/api-reference/octet-start/_ --- # `OctetSdk` The handle returned by [`Octet.start(...)`](/api-reference/octet-start). Holds the predicate surface (`loc`) and a snapshot of license state (`licenseStatus`). ## Shape ```swift public final class OctetSdk: @unchecked Sendable { public let loc: OctetLoc public internal(set) var licenseStatus: LicenseStatus? } ``` ```kotlin class OctetSdk { val loc: OctetLoc var licenseStatus: LicenseStatus? internal set } ``` `licenseStatus` is optional in the type. Callers that obtained `sdk` via `Octet.start(...)` always see a non-nil value, since `start` attaches it before returning. ## `loc`. The predicate surface. `OctetSdk.loc` is the only way to issue a predicate query. See [Predicates](/api-reference/predicates) for `isWithin`, `isOutside`, `contains`. ## `licenseStatus`. Current license snapshot. Read synchronously at any time. See [License Types](/api-reference/license-types) for the field shape. ```swift if let status = sdk.licenseStatus { print("\(status.state): \(status.daysUntilHardStop ?? -1) days left") } ``` ```kotlin sdk.licenseStatus?.let { status -> println("${status.state}: ${status.daysUntilHardStop ?: -1} days left") } ``` ## Lifecycle notes - The SDK does not expose a `stop()` or `shutdown()` at v1. The proof pipeline runs for the lifetime of the process. The system shuts it down with the process. - Calling `Octet.start(...)` a second time within the same process is not supported in v1. Hold the first `OctetSdk` for as long as you need predicates. - `OctetSdk` is `Sendable` (Swift) and thread-safe to use from any coroutine context (Kotlin). The predicates serialize internally. ## See also - [`Octet.start(...)`](/api-reference/octet-start). How to obtain an `OctetSdk`. - [Predicates](/api-reference/predicates). The methods on `sdk.loc`. - [`OctetVerdict`](/api-reference/octet-verdict). What predicates return. _Source: https://octetproof.com/docs/api-reference/octet-sdk/_ --- # Predicates The three predicate methods on `OctetSdk.loc`. All three are `async` (Swift) / `suspend` (Kotlin) and **do not throw**. Runtime problems surface as `INDETERMINATE` verdicts, not exceptions. ## `isWithin` The primary predicate. Reads as: *"my currently provable location is inside `region`."* ```swift public func isWithin( region: OctetRegion, atTime: Date = Date(), sessionNonce: Data? = nil ) async -> OctetVerdict ``` ```kotlin suspend fun isWithin( region: OctetRegion, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, ): OctetVerdict ``` ### Example ```swift let v = await sdk.loc.isWithin( region: .country(isoCode: "US"), atTime: Date() ) if v.result == .yes { ship(v.proof!) } ``` ```kotlin val v = sdk.loc.isWithin( region = OctetRegion.country("US"), atTime = Instant.now() ) if (v.result == OctetVerdict.Result.YES) ship(v.proof!!) ``` --- ## `isOutside` Reads as: *"my currently provable location is outside `region`."* ```swift public func isOutside( region: OctetRegion, atTime: Date = Date(), sessionNonce: Data? = nil ) async -> OctetVerdict ``` ```kotlin suspend fun isOutside( region: OctetRegion, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, ): OctetVerdict ``` :::caution **`isOutside` is not `!isWithin`.** An `INDETERMINATE` is not `outside`. The separate call lets the caller assert the negative claim and get a *proof of the negative*. A verifier can check that proof just as easily as a `YES`. Negating an `INDETERMINATE` would erase the difference between "I'm not inside" and "I can't tell". ::: --- ## `contains` Reads from the device's perspective: *"my location contains this point within `tol` meters."* ```swift public func contains( center: LatLon, tol: Meters, atTime: Date = Date(), sessionNonce: Data? = nil ) async -> OctetVerdict ``` ```kotlin suspend fun contains( center: LatLon, tol: Meters, atTime: Instant = Instant.now(), sessionNonce: ByteArray? = null, ): OctetVerdict ``` Mathematically equivalent to `isWithin(region: .disc(center: center, radiusMeters: tol), atTime:)`. Kept as a distinct verb because the device-centric reading is the natural one for proximity use cases ("am I near the delivery dropoff", "did I arrive at the geofence"). ### Rolling your own `isWithin` is the load-bearing primitive. `contains` is a thin one-liner over it. The same trick rolls custom predicates: ```swift extension OctetLoc { func isNear(_ center: LatLon, _ tol: Meters) async -> OctetVerdict { await isWithin(region: .disc(center: center, radiusMeters: tol)) } func isInsideAny(_ regions: [OctetRegion]) async -> OctetVerdict? { for r in regions { let v = await isWithin(region: r) if v.result == .yes { return v } } return nil } } ``` ```kotlin suspend fun OctetLoc.isNear(center: LatLon, tol: Meters): OctetVerdict = isWithin(OctetRegion.disc(center, tol)) suspend fun OctetLoc.isInsideAny(regions: List): OctetVerdict? { for (r in regions) { val v = isWithin(r) if (v.result == OctetVerdict.Result.YES) return v } return null } ``` ## Time semantics All three predicates take an optional `atTime`. See [Time Semantics](/concepts/time-semantics) for the live / historical / future regimes and the per-resolution validity windows. Default is "now". Future times beyond ±2 s come back `INDETERMINATE / FUTURE_TIME`. ## Session-binding All three predicates take an optional `sessionNonce`, added in 1.2. Pass the one-time nonce your login backend issued for this login. The SDK commits a hash of the nonce inside the signed proof, so your verifier can confirm the proof was made for that login. Only the hash is serialized, never the raw bytes. Omitting `sessionNonce` produces the same proof as 1.1. ```swift let nonce = try await loginBackend.issueNonce() // one-time login nonce let v = await sdk.loc.isWithin( region: .country(isoCode: "US"), sessionNonce: nonce ) if v.result == .yes { send(v.proof!, to: loginBackend) } ``` ```kotlin val nonce: ByteArray = loginBackend.issueNonce() // one-time login nonce val v = sdk.loc.isWithin( region = OctetRegion.country("US"), sessionNonce = nonce, ) if (v.result == OctetVerdict.Result.YES) send(v.proof!!, loginBackend) ``` The nonce is 1 to 512 bytes. An empty or larger nonce returns `INDETERMINATE / INVALID_SESSION_NONCE` with no proof. A session-bound call always generates a fresh proof rather than serving a cached one. Forward the returned `verdict.proof` to your backend, then verify it with `octet-verify` 1.2.0 or later. A verifier below 1.2.0 reports the session binding as `NOT-CHECKED` and validates the rest of the proof. [Session-binding](/concepts/session-binding) explains the mechanism and what it defends against. ## See also - [`OctetRegion`](/api-reference/octet-region). The shapes you can pass. - [`OctetVerdict`](/api-reference/octet-verdict). What you get back. - [Session-binding](/concepts/session-binding). Binding a proof to a login. - [Verdicts](/concepts/verdicts). The trichotomy and the `ReasonCode` taxonomy. _Source: https://octetproof.com/docs/api-reference/predicates/_ --- # `OctetRegion` The shape a predicate is evaluated against. Constructed via static factory methods that validate inputs eagerly. Invalid regions trap at construction. ## Factories ### `country`. Verified via MCC. ```swift public static func country(isoCode: String) -> OctetRegion // e.g. OctetRegion.country(isoCode: "US") ``` ```kotlin @JvmStatic fun country(isoCode: String): CountryRegion // e.g. OctetRegion.country("US") ``` `isoCode` must be ISO 3166-1 alpha-2 (two uppercase letters). Verified via mobile country code from the serving cell tower and network operator. Usually works indoors and without GPS. ### `subdivision`. ISO 3166-2. ```swift public static func subdivision(isoCode: String) -> OctetRegion // e.g. OctetRegion.subdivision(isoCode: "US-CA") ``` ```kotlin @JvmStatic fun subdivision(isoCode: String): SubdivisionRegion // e.g. OctetRegion.subdivision("US-CA") ``` Country code, hyphen, 1–3 alphanumeric chars. Verified at proof time by reverse-geocoding the trusted fix's admin area. ### `usState`. Sugar for `subdivision`. ```swift public static func usState(_ stateCode: String) -> OctetRegion // OctetRegion.usState("CA") == OctetRegion.subdivision(isoCode: "US-CA") ``` ```kotlin @JvmStatic fun usState(stateCode: String): SubdivisionRegion // OctetRegion.usState("CA") == OctetRegion.subdivision("US-CA") ``` Takes a standard two-letter US postal abbreviation. ### `city`. Name-resolved. ```swift public static func city(name: String) -> OctetRegion // e.g. OctetRegion.city(name: "San Francisco") ``` ```kotlin @JvmStatic fun city(name: String): CityRegion // e.g. OctetRegion.city("San Francisco") ``` The SDK resolves `name` to an H3 polygon set internally (bundled atlas for top-N cities, server lookup beyond that). ### `disc`. Analytic circle. ```swift public static func disc(center: LatLon, radiusMeters: Meters) -> OctetRegion // e.g. OctetRegion.disc(center: LatLon(37.422, -122.084), radiusMeters: 250) ``` ```kotlin @JvmStatic fun disc(center: LatLon, radiusMeters: Meters): EllipseRegion // e.g. OctetRegion.disc(LatLon(37.422, -122.084), 250.0) ``` A disc is stored internally as a degenerate ellipse (equal axes, heading 0). The serializer renders this special case as `disc(lat,lon,r)` for readability. ### `ellipse`. Oriented 2D ground ellipse. ```swift public static func ellipse( center: LatLon, semiMajorM: Meters, semiMinorM: Meters, headingDeg: Double // [0, 360), clockwise from north ) -> OctetRegion ``` ```kotlin @JvmStatic fun ellipse( center: LatLon, semiMajorM: Meters, semiMinorM: Meters, headingDeg: Double, // [0, 360), clockwise from north ): EllipseRegion ``` Pre-validated: `semiMinorM > 0`, `semiMajorM >= semiMinorM`, `headingDeg ∈ [0, 360)`. ### `box3D`. Bounding box. ```swift public static func box3D( latRange: ClosedRange, lonRange: ClosedRange, altRange: ClosedRange ) -> OctetRegion ``` ```kotlin @JvmStatic fun box3D( latRange: ClosedFloatingPointRange, lonRange: ClosedFloatingPointRange, altRange: ClosedFloatingPointRange, ): BoundingBox3DRegion ``` Latitude validated to `[-90, 90]`, longitude to `[-180, 180]`, altitude finite. ### `polygonSet`. Union of H3 cells. ```swift public static func polygonSet(cells: [H3Cell]) -> OctetRegion ``` ```kotlin @JvmStatic fun polygonSet(cells: List): PolygonSetRegion ``` Non-empty cell list. Free-form lat/lon polygons are not accepted. Quantize to H3 ahead of time. See [Regions](/concepts/regions) for why. ### `earth`. Defensive fallback. ```swift public static func earth(maxAltitudeMeters: Meters = 10_000) -> OctetRegion ``` ```kotlin @JvmStatic fun earth(maxAltitudeMeters: Meters = 10_000.0): EarthRegion ``` `isWithin(.earth(...))` always returns `YES` if the SDK can produce any proof at all. ## Value types ```swift public struct LatLon: Sendable, Hashable { public let latitude: Double // [-90, 90] public let longitude: Double // [-180, 180] } public typealias Meters = Double ``` ```kotlin data class LatLon(val latitude: Double, val longitude: Double) { // validated [-90,90] and [-180,180] at construction } typealias Meters = Double @JvmInline value class H3Cell(val cellId: ULong) // 64-bit H3 cell index ``` ## Construction helpers (Android only at v1) ### `RegionSpec` + `getRegion` ```kotlin sealed class RegionSpec { data class Country(val isoCode: String) : RegionSpec() data class Subdivision(val isoCode: String) : RegionSpec() data class City(val name: String) : RegionSpec() data class NamedZone(val id: String) : RegionSpec() } suspend fun getRegion(spec: RegionSpec): OctetRegion ``` `country` and `subdivision` are local and return immediately. `city` and `namedZone` are declared but currently throw `OctetFutureFlag`. Atlas and server lookup are not yet wired. ### `buildRegion { … }` DSL ```kotlin val r = buildRegion { disc(center = LatLon(37.422, -122.084), radiusMeters = 250.0) } ``` Sugar over the static factories. Synchronous. Same validation rules. ## Inspecting a region Available on all platforms via the uniform `.toStr()` / `.toJson()` / `.toJsonl()` methods. See [Serialization](/api-reference/serialization). Android additionally exposes the free functions: ```kotlin fun whatisRegion(r: OctetRegion): String // human, NOT stable fun regionToStr(r: OctetRegion): String // machine, STABLE wire form ``` ## See also - [Regions](/concepts/regions). Why the taxonomy looks the way it does. - [Predicates](/api-reference/predicates). What consumes an `OctetRegion`. - [Serialization](/api-reference/serialization). `.toStr()` / `.toJson()` / `.toJsonl()` on every region. _Source: https://octetproof.com/docs/api-reference/octet-region/_ --- # `OctetVerdict` The outcome of a predicate call. Returned by `isWithin`, `isOutside`, `contains` on `OctetSdk.loc`. ## Shape ```swift public struct OctetVerdict: Sendable { public let result: Result public let reason: ReasonCode public let message: String public let proof: LocationProof? public let validity: Interval? public let queriedAt: Date public let confidence: ConfidenceSummary public let achievableLevel: ProofLevel? // non-nil only when reason == .insufficientPrecision public enum Result: Sendable, Hashable { case yes, no, indeterminate } public enum ReasonCode: Sendable, Hashable { case ok case noFix case futureTime case staleFix case noProofAtResolution case insufficientPrecision case attestationFailed case mockLocationDetected case sdkNotRunning case notYetReleased case invalidSessionNonce } } ``` ```kotlin data class OctetVerdict( val result: Result, val reason: ReasonCode, val message: String, val proof: LocationProof?, val validity: Interval?, val queriedAt: Instant, val confidence: ConfidenceSummary, val achievableLevel: ProofLevel? = null, // non-null only when reason == INSUFFICIENT_PRECISION ) { enum class Result { YES, NO, INDETERMINATE } enum class ReasonCode { OK, NO_FIX, FUTURE_TIME, STALE_FIX, NO_PROOF_AT_RESOLUTION, INSUFFICIENT_PRECISION, ATTESTATION_FAILED, MOCK_LOCATION_DETECTED, SDK_NOT_RUNNING, NOT_YET_RELEASED, INVALID_SESSION_NONCE, } } ``` ## Field invariants - **`proof` is `nil`/`null` iff `result == .indeterminate`/`INDETERMINATE`.** Enforced as a precondition / `require` at construction. A `YES` or `NO` always carries a proof. An `INDETERMINATE` never does. - **`validity` is non-nil exactly when `proof` is non-nil.** Both move together. - **`queriedAt` is the `atTime` that was passed in.** Echoed back so callers do not have to thread it themselves. - **`confidence` is always populated**, even for `INDETERMINATE` (with an empty summary in that case). - **`achievableLevel` is non-nil only when `reason == .insufficientPrecision`.** It names the best [proof level](/concepts/regions) the SDK could reach for this query right now. New in 1.1. The SDK never silently down-levels to it on your behalf. ## `result`. The trichotomy. | Value | Meaning | |---|---| | `.yes` / `YES` | Predicate holds. Proof attached. | | `.no` / `NO` | Predicate provably does NOT hold. Proof attached. | | `.indeterminate` / `INDETERMINATE` | SDK cannot answer. See `reason`. | Never collapse `.indeterminate` to `.no` silently. See [Verdicts](/concepts/verdicts) for the rationale. ## `reason`. The `ReasonCode`. | Code | Result | Meaning | |---|---|---| | `ok` / `OK` | `YES` or `NO` | Proof covered `atTime`. Predicate evaluated cleanly. | | `noFix` / `NO_FIX` | `INDETERMINATE` | SDK started but no fix yet (also: running on simulator / emulator). | | `futureTime` / `FUTURE_TIME` | `INDETERMINATE` | `atTime` is in the future beyond ±2 s clock-skew tolerance. | | `staleFix` / `STALE_FIX` | `INDETERMINATE` | `atTime` falls outside the validity window of any cached proof. | | `noProofAtResolution` / `NO_PROOF_AT_RESOLUTION` | `INDETERMINATE` | Cached proof is too coarse for the query (country proof, city query). | | `insufficientPrecision` / `INSUFFICIENT_PRECISION` | `INDETERMINATE` | Conditions cannot support the requested precision. Read `achievableLevel`. | | `attestationFailed` / `ATTESTATION_FAILED` | `INDETERMINATE` | Play Integrity / App Attest verdict not COMPLIANT. | | `mockLocationDetected` / `MOCK_LOCATION_DETECTED` | `INDETERMINATE` | OS flagged mocked location. | | `sdkNotRunning` / `SDK_NOT_RUNNING` | `INDETERMINATE` | `Octet.start(...)` was never called or session ended. | | `notYetReleased` / `NOT_YET_RELEASED` | `INDETERMINATE` | Predicate path declared by the API but not wired in this SDK build. | | `invalidSessionNonce` / `INVALID_SESSION_NONCE` | `INDETERMINATE` | Supplied `sessionNonce` is empty or exceeds 512 bytes. No proof is produced. Added in 1.2. | ## `message`. For humans only. `message` is a free-form string useful for log lines and debug UI. **Do not parse `message` for control flow.** Parse `reason` instead. The text is intentionally informal. The SDK reserves the right to refine it across versions. ## `proof`. The cryptographic witness. `LocationProof` is the signed protobuf that a verifier checks against Octet's public keys. Its full shape is part of the protocol, not the SDK's public surface. See [Serialization](/api-reference/serialization) for the v1 alpha caveat about how `LocationProof` is currently rendered through `.toJson()`. For most integrators the proof is opaque. Forward it to your verifier as-is. ## `validity`. The proof's temporal window. The interval over which the underlying proof answers the predicate as stated. **Not** "how long the verdict will stay true". See [Time Semantics](/concepts/time-semantics). ```swift public struct Interval: Sendable, Hashable { public let from: Date public let to: Date } ``` ```kotlin data class Interval(val from: Instant, val to: Instant) ``` Inclusive-inclusive, `from <= to`. ## `confidence`. The score and flags. `ConfidenceSummary` surfaces the SDK's overall confidence score and a list of flags (mock-location detected, VPN active, GNSS anomaly, and so on). A strict caller can treat a low-confidence `YES` as effectively `INDETERMINATE`: ```swift let strictYes = verdict.result == .yes && verdict.confidence.overallScore > 0.8 ``` ```kotlin val strictYes = verdict.result == OctetVerdict.Result.YES && verdict.confidence.overallScore > 0.8 ``` The SDK never applies this collapse for you. The policy lives on the caller side. ## See also - [Verdicts](/concepts/verdicts). Why the trichotomy matters. - [Time Semantics](/concepts/time-semantics). How `validity` and `queriedAt` interact. - [Serialization](/api-reference/serialization). `.toStr()` / `.toJson()` / `.toJsonl()` on verdicts. _Source: https://octetproof.com/docs/api-reference/octet-verdict/_ --- # License Types The public types backing [`Octet.start(...)`](/api-reference/octet-start) and `OctetSdk.licenseStatus`. See [License & Activation](/concepts/license-activation) for the timeline model. ## `LicenseStatus` Snapshot of the SDK's current license state. Read synchronously from `OctetSdk.licenseStatus`. ```swift public struct LicenseStatus: Sendable, Equatable { public let state: LicenseState public let activatedAt: Date? public let hardStopAt: Date? public let daysUntilHardStop: Int? public let tier: String public let upgradeRecommended: Bool // soft version hint; false until backend gating is on public let minSupportedVersion: String? // soft version hint; nil until backend gating is on } ``` ```kotlin data class LicenseStatus( val state: LicenseState, val activatedAt: Instant?, val hardStopAt: Instant?, val daysUntilHardStop: Int?, val tier: String, val upgradeRecommended: Boolean, // soft version hint; false until backend gating is on val minSupportedVersion: String?, // soft version hint; null until backend gating is on ) ``` | Field | Meaning | |---|---| | `state` | Coarse state that drives in-app UI. See `LicenseState` below. | | `activatedAt` | First successful activation timestamp. `nil` pre-activation, or in `INVALID`. | | `hardStopAt` | The license's `exp`. Octet maintains active licenses, so this advances as the key renews -- do not design around a fixed cutoff. Identical for every device that activates this license. | | `daysUntilHardStop` | `ceil((hardStopAt - now) / 1 day)`. Useful for "X days left" banners. | | `tier` | Tier carried in the license token. | | `upgradeRecommended` | Soft version hint, added in 1.2. `true` when the backend reports this SDK version as behind the recommended floor. `false` until backend version gating is enabled. | | `minSupportedVersion` | Soft version hint, added in 1.2. The lowest SDK version the backend supports, or `nil`/`null` until gating is enabled. Pair it with `upgradeRecommended` to prompt an in-app update. | ## `LicenseState` Octet maintains active licenses, so a key in normal use stays in `ACTIVE`. `RENEWAL_RECOMMENDED`, `GRACE_PERIOD`, and `EXPIRED` cover prolonged-offline and revocation handling -- not an age-based trial clock. ```swift public enum LicenseState: String, Sendable { case notActivated = "NOT_ACTIVATED" case active = "ACTIVE" case renewalRecommended = "RENEWAL_RECOMMENDED" case gracePeriod = "GRACE_PERIOD" case expired = "EXPIRED" case invalid = "INVALID" } ``` ```kotlin enum class LicenseState { NOT_ACTIVATED, ACTIVE, RENEWAL_RECOMMENDED, GRACE_PERIOD, EXPIRED, INVALID, } ``` | State | Meaning | |---|---| | `NOT_ACTIVATED` | License verified, never activated. | | `ACTIVE` | Activated and current. The normal steady state for a maintained key. | | `RENEWAL_RECOMMENDED` | Activated, nearing the license `exp`. Maintained keys renew automatically. Surface a banner only if you want to. | | `GRACE_PERIOD` | Cached activation past its `exp` but within the offline tolerance -- the device could not reach the backend to refresh. SDK keeps working. | | `EXPIRED` | Cached activation past the offline tolerance with no backend reachable to refresh. SDK refused to start. Age alone no longer expires a maintained key. | | `INVALID` | Signature failed, server rejected, or malformed. SDK refused to start. | :::caution `LicenseState` drives in-app UI only. It **never** drives the cryptographic gate. That is enforced by the activation token. Forging `state = ACTIVE` accomplishes nothing. ::: ## `LicenseError` Thrown by `Octet.start(...)` for every license-related failure. The SDK guarantees one of these subtypes. It never throws a raw `Error` / `Exception` for license reasons. ```swift public enum LicenseError: Error, Sendable, Equatable { case malformedKey case noActivation case expired case activationWindowClosed case revoked case network(message: String) case serverRejected(httpStatus: Int, reason: String) case upgradeRequired(minVersion: String, message: String) } ``` ```kotlin sealed class LicenseError(message: String) : Exception(message) { object MalformedKey : LicenseError(...) object NoActivation : LicenseError(...) object Expired : LicenseError(...) object ActivationWindowClosed : LicenseError(...) object Revoked : LicenseError(...) data class Network(override val cause: Throwable) : LicenseError(...) data class ServerRejected(val httpStatus: Int, val reason: String) : LicenseError(...) data class UpgradeRequired(val minVersion: String, val message: String) : LicenseError(...) } ``` | Case | When | Fix | |---|---|---| | `MalformedKey` | Local PASETO signature / structural failure on the license key. | Re-copy the key. | | `NoActivation` | No cached activation, and offline (can't reach `/v1/activate`). | Retry when network returns. | | `Expired` | Cached activation past the offline grace with the backend unreachable to refresh. | Reconnect so the SDK can re-activate. | | `ActivationWindowClosed` | Server returned `403 activation_window_closed`. Not returned under the current maintained-license model. Retained for compatibility. | Contact support. | | `Revoked` | Server returned `403 revoked` (admin revoke). | Contact support. | | `Network` | Transient network failure during activation. `message` (iOS) or `cause` (Android) carries the underlying error for diagnostics. | Retry. Do not parse the message for control flow. | | `ServerRejected` | Any other HTTP rejection (`app_blocked`, `ip_blocked`, …). | Inspect `reason`. | | `UpgradeRequired` | Backend rejected this SDK version as out of support (version gating, added in 1.2). Not returned in 1.2: the backend gates no version yet. | Update the SDK to `minVersion` or later. | ## Reading the status at runtime ```swift guard let status = sdk.licenseStatus else { return } switch status.state { case .renewalRecommended: showBanner("Renew within \(status.daysUntilHardStop ?? 0) days") case .gracePeriod: showBanner("Grace period: renew before the SDK stops") case .expired, .invalid: showBlocker() case .active, .notActivated: break // happy path } ``` ```kotlin val status = sdk.licenseStatus ?: return when (status.state) { LicenseState.RENEWAL_RECOMMENDED -> showBanner("Renew within ${status.daysUntilHardStop ?: 0} days") LicenseState.GRACE_PERIOD -> showBanner("Grace period: renew before the SDK stops") LicenseState.EXPIRED, LicenseState.INVALID -> showBlocker() LicenseState.ACTIVE, LicenseState.NOT_ACTIVATED -> { // happy path } } ``` ## See also - [License & Activation](/concepts/license-activation). The timeline model and activation flow. - [`Octet.start(...)`](/api-reference/octet-start). The call that throws `LicenseError`. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when a key fails. _Source: https://octetproof.com/docs/api-reference/license-types/_ --- # Serialization Every public API object exposes three uniform forms. ## The three methods | Method | Purpose | Stability | |---|---|---| | `.toStr()` | Single-line human-readable summary | **Not stable**. For logs and debug UI only. | | `.toJson()` | Pretty JSON, full structure | **Stable**. HTTP bodies, files, verifier input. | | `.toJsonl()` | Single-line JSON, no whitespace | **Stable**. Line-delimited log sinks, streaming. | ## Where they're available `.toStr()` / `.toJson()` / `.toJsonl()` are defined on: - `OctetRegion` - `OctetVerdict` - `LatLon` - `H3Cell` - `Interval` ## Examples ### `OctetRegion` ```swift let r = OctetRegion.disc(center: LatLon(37.422, -122.084), radiusMeters: 250) r.toStr() // "Disc at (37.4220, -122.0840), r=250 m" r.toJsonl() // {"shape":"ellipse","center":{"latitude":37.422,...},...} ``` ```kotlin val r = OctetRegion.disc(LatLon(37.422, -122.084), 250.0) r.toStr() // "Disc at (37.4220, -122.0840), r=250 m" r.toJsonl() // {"shape":"ellipse","center":{"latitude":37.422,...},...} ``` Discs round-trip as ellipses in the JSON form (the underlying type is `EllipseRegion` with equal axes). The human-readable `.toStr()` form keeps the "Disc" label for readability. ### `OctetVerdict` ```swift verdict.toStr() // "YES reason=OK atTime=2026-05-28T15:30:00.000Z validity=[...] conf=0.92" verdict.toJson() // { // "result": "YES", // "reason": "OK", // "message": "isWithin evaluated on cached proof", // "queried_at": "2026-05-28T15:30:00.000Z", // "validity": { "from": "...", "to": "..." }, // "proof": { "__future_flag__": "...", "id": "...", ... }, // "confidence": { "__future_flag__": "...", "overall_score": 0.92, ... } // } ``` ```kotlin verdict.toStr() // "YES reason=OK atTime=2026-05-28T15:30:00.000Z validity=[...] conf=0.92" verdict.toJson() // { // "result": "YES", // ... // } ``` ## v1 caveat: proto-backed fields are placeholders `LocationProof` and `ConfidenceSummary` are protobuf-backed types. Their full proto-JSON encoding is **deferred to a later SDK release** to keep platform output consistent (the Android side uses `protobuf-javalite`, which lacks `JsonFormat`). For v1, `.toJson()` emits a placeholder object for those nested structures: ```json "proof": { "__future_flag__": "OCTET_FUTURE_FLAG :: LocationProof full proto-JSON", "id": "...", "timestamp_ms": 1748462400000, "sdk_version": "1.2.1", "platform": "ios" } ``` The `__future_flag__` marker is the SDK's signal that this field's full encoding is not yet stable. Identifying fields (`id`, `timestamp_ms`, `sdk_version`, `platform`) are populated, so log diffs and routing remain useful. When a predicate runs with a `sessionNonce` (see [Session-binding](/concepts/session-binding)), the proof material commits a hash of that nonce. Only the hash is serialized, never the raw nonce. The stability commitment in the table above applies to non-proto fields today. The proto fields become stable when the proto-JSON path lands. **Until then, integrators who need to forward proof material to a verifier should pass the `LocationProof` value itself** (it is a typed proto on both platforms), not `verdict.toJson()`. ## Helper free functions (Android) In addition to the methods, Android exposes: ```kotlin fun whatisRegion(r: OctetRegion): String // equivalent to r.toStr(); NOT stable fun regionToStr(r: OctetRegion): String // STABLE canonical machine form ``` `regionToStr` is the stable wire form behind `region.toJson()`'s `shape`, `iso_code`, and other fields. Same content, more compact representation, suitable for log diffs and idempotency keys. iOS exposes only the method form: `region.toStr()`. ## See also - [`OctetRegion`](/api-reference/octet-region) - [`OctetVerdict`](/api-reference/octet-verdict) - [License Types](/api-reference/license-types). No `.toStr()` etc. on license types at v1. Read fields directly. _Source: https://octetproof.com/docs/api-reference/serialization/_ --- # iOS Sample: OctetSample Minimal demo of the OctetSDK public API: **one button, one verdict**. Use it to confirm your environment is set up correctly and to play with the predicate API without the noise of a full app. The sample lives under [`octet-sdk-ios/sample/`](https://github.com/octetproof/octet-sdk-ios/tree/main/sample). The same instructions below also live in [`sample/README.md`](https://github.com/octetproof/octet-sdk-ios/blob/main/sample/README.md) in that repository. Handy if you're working offline. ## What it does - Requests `NSLocationWhenInUseUsageDescription` on launch. - Calls `try await Octet.start(config: OctetConfig(licenseKey: …))`. The SDK verifies your license, activates against `api.octetproof.com/v1/activate` on first run, caches the token in Keychain, and brings up the proof pipeline. - On button tap, runs `await sdk.loc.isWithin(region: .country(isoCode: "US"), atTime: Date())` and renders the verdict (result / reason / message / whether a proof attached). ## Build and run ### 1. Get a license key Key from [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup); the first 1,000 active users each month are free. ### 2. Configure your local copy ```bash cd octet-sdk-ios/sample cp LocalConfig.swift.example LocalConfig.swift ``` Open `LocalConfig.swift` and paste your key: ```swift enum LocalConfig { static let licenseKey = "octet_live_v4.public.…" } ``` `LocalConfig.swift` is gitignored. Your key stays local. If you skip this step, the build fails with `cannot find 'LocalConfig' in scope`. ### 3. Generate the Xcode project The repo ships an [XcodeGen](https://github.com/yonaskolb/XcodeGen) spec rather than a committed `.xcodeproj`. Install once: ```bash brew install xcodegen ``` Then generate: ```bash xcodegen generate ``` ### 4. Build and run Pick whichever path fits your workflow. #### Using Xcode (recommended) ```bash open OctetSample.xcodeproj ``` In Xcode: 1. Select the **OctetSample** target → **Signing & Capabilities** → tick *Automatically manage signing* and pick your team. 2. Plug in your iOS device. The simulator cannot produce proofs (see below). 3. ⌘R to build and run. The free Apple Developer tier works. Apps installed via free provisioning expire after 7 days. A paid Apple Developer Program account removes the expiry. #### From the command line To compile-check without installing on a device: ```bash xcodebuild \ -project OctetSample.xcodeproj \ -scheme OctetSample \ -destination 'generic/platform=iOS' \ -configuration Debug \ build ``` Running on a connected device from CLI requires either [`ios-deploy`](https://github.com/ios-control/ios-deploy) (`brew install ios-deploy`) or `xcrun devicectl device install`, plus a valid signing setup. For day-to-day work the Xcode path is much easier. The command-line path is mainly for CI / build verification. ### 5. First-launch permissions Two prompts: - **Location When In Use**. Grant it. The SDK refuses to start without location auth. - **Motion & Fitness**. Grant it. The SDK touches `CMMotionActivityManager` at init and Apple requires the usage description even for read-only access. Then tap the button to fire a verdict. ## Annotated source The whole sample is ~80 lines of SwiftUI. The interesting bits: ```swift // Bring up the SDK once permission is granted. let config = OctetConfig(licenseKey: LocalConfig.licenseKey) let sdk = try await Octet.start(config: config) ``` ```swift // Fire a verdict on button tap. let verdict = await sdk.loc.isWithin( region: .country(isoCode: "US"), atTime: Date() ) ``` ```swift // Render it. """ result: \(verdict.result) reason: \(verdict.reason) message: \(verdict.message) proof: \(verdict.proof != nil ? "set" : "nil") """ ``` ## Why simulator runs always return `INDETERMINATE` `isWithin(.country(...))` from the iOS Simulator returns: ``` result: INDETERMINATE reason: NO_FIX message: running on simulator — location proofs are unavailable in this environment ``` This is by design. The iOS Simulator has no real GNSS or motion stack, and the SDK's spoof-detection pipeline blocks proof generation. **Run on a real device** to see the full flow. ## Requirements - iOS 16.0+ on the device - Xcode 15+ - A valid OctetSDK license key ## See also - [iOS Quick Start](/getting-started/ios-quickstart). Integrating the SDK into your own app from scratch. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when verdicts don't look right. _Source: https://octetproof.com/docs/samples/ios-toy-app/_ --- # Android Sample: OctetSample Minimal demo of the OctetSDK public API: **one button, one verdict**. Use it to confirm your environment is set up correctly and to play with the predicate API without the noise of a full app. The sample lives under [`octet-sdk-android/sample/`](https://github.com/octetproof/octet-sdk-android/tree/main/sample). It is a standalone Gradle project. `git clone`, configure your license key, and build. The SDK is resolved from the parent repo's Maven branch. There is no source dependency. The same instructions below also live in [`sample/README.md`](https://github.com/octetproof/octet-sdk-android/blob/main/sample/README.md) in that repository. Handy if you're working offline. ## What it does - Requests `ACCESS_FINE_LOCATION` at runtime. - Calls `Octet.start(context, OctetConfig(licenseKey = …))`. The SDK verifies your license, activates against `api.octetproof.com/v1/activate` on first run, caches the token in `EncryptedSharedPreferences`, and brings up the proof pipeline. - On button tap, runs `sdk.loc.isWithin(OctetRegion.country("US"), Instant.now())` and renders the verdict. ## Build and run ### 1. Get a license key Key from [sdk.octetproof.com/signup](https://sdk.octetproof.com/signup); the first 1,000 active users each month are free. ### 2. Configure your local copy ```bash cd octet-sdk-android/sample cp local.properties.example local.properties ``` Open `local.properties` and fill in: - **`octet.licenseKey`**. Paste your key. Missing it isn't a build error. The app launches and throws `LicenseError.MalformedKey` at `Octet.start`. - **`sdk.dir`**. Android SDK install path. Android Studio writes this automatically the first time you open the project, so leave it commented out if you only build from the IDE. If you build from the CLI (`./gradlew …`) on a machine that hasn't run Studio against this project yet, uncomment and set the path (e.g. `/Users//Library/Android/sdk` on macOS) or export `ANDROID_HOME` in your shell. `local.properties` is gitignored. ### 3. Enable USB debugging Settings → About → tap *Build number* 7× to unlock Developer options, then enable *USB debugging*. Confirm: ```bash adb devices ``` ### 4. Build and run Pick whichever path fits your workflow. #### Using Android Studio (recommended) 1. **File → Open** → select the `octet-sdk-android/sample/` folder. 2. Wait for the initial Gradle sync to finish (~1 min the first time). 3. Plug in your device (USB debugging enabled per step 3). 4. Click the green **Run ▶** button (or ⇧F10 / ⌃R on macOS). Studio handles the install, launch, and log streaming. Subsequent runs are quick. #### From the command line Install in one step: ```bash ./gradlew :app:installDebug ``` Or build the APK and sideload manually: ```bash ./gradlew :app:assembleDebug adb install -r app/build/outputs/apk/debug/app-debug.apk ``` ### 5. First-launch permissions A single prompt for **Location**. Grant it. The SDK refuses to start otherwise. Tap the button to fire a verdict. ## Annotated source The whole activity is ~90 lines. The interesting bits: ```kotlin // Bring up the SDK once permission is granted. lifecycleScope.launch { val config = OctetConfig(licenseKey = BuildConfig.OCTET_LICENSE_KEY) val sdk = Octet.start(this@MainActivity, config) } ``` ```kotlin // Fire a verdict on button tap. val verdict = sdk.loc.isWithin( region = OctetRegion.country("US"), atTime = Instant.now() ) ``` ```kotlin // Render it. buildString { append("result: ").append(verdict.result).append('\n') append("reason: ").append(verdict.reason).append('\n') append("message: ").append(verdict.message).append('\n') append("proof: ").append(verdict.proof != null) } ``` The license key is wired through `BuildConfig.OCTET_LICENSE_KEY`, generated from the `octet.licenseKey` line in `local.properties` at build time. ## Why emulator runs always return `INDETERMINATE` `isWithin(country("US"))` from the Android emulator returns: ``` result: INDETERMINATE reason: NO_FIX message: running on emulator — location proofs are unavailable in this environment ``` This is by design. The emulator's "telnet geo fix" provider sets `Location.isMock = true`, and the SDK's spoof-detection pipeline blocks proof generation. **Run on a real device** to see the full flow. The SDK detects Android Studio AVDs (`goldfish` / `ranchu` / `sdk_gphone`), Genymotion, BlueStacks, and other generic / test-keys images. Custom ROMs that rewrite `Build.*` fields can defeat this. The check is a developer hint, not a security gate. ## A pre-built APK on every release Each [GitHub Release](https://github.com/octetproof/octet-sdk-android/releases) attaches an unsigned debug-style APK for shape verification. It still needs a per-developer Octet license key to run. Wire it via `octet.licenseKey` in `local.properties` at build time, or `BuildConfig.OCTET_LICENSE_KEY` at runtime. ## Requirements - Android 11 (API 30)+ on the device - Android Studio Hedgehog (2023.1.1)+ - JDK 17 - A valid OctetSDK license key ## See also - [Android Quick Start](/getting-started/android-quickstart). Integrating the SDK into your own app from scratch. - [Troubleshooting FAQ](/troubleshooting/faq). What to do when verdicts don't look right. _Source: https://octetproof.com/docs/samples/android-toy-app/_ --- # Troubleshooting & FAQ Indexed by the symptom you see. Each entry gives the cause and the fix. --- ## Verdict reasons ### `INDETERMINATE / NO_FIX` on emulator or simulator ``` result: INDETERMINATE reason: NO_FIX message: running on simulator — location proofs are unavailable in this environment (Android: "running on emulator — …") ``` **Why.** The simulator (iOS) has no real GNSS or motion stack. The Android emulator's "telnet geo fix" provider sets `Location.isMock = true`. Either way, the SDK's spoof-detection pipeline blocks proof generation. **Fix.** Run on a real device. It will never work on a simulator or emulator, and waiting will not help. The underlying tamper signals (mock-location flag, jailbreak / root indicators, GNSS anomalies, attestation results) are **deliberately not** surfaced in `verdict.message`. Exposing which signal fired is a security risk: it tells an attacker exactly what to defeat. --- ### `INDETERMINATE / NO_FIX` on a real device The SDK started but has not seen a usable fix yet, or every fix so far has been rejected by spoof detection. **Fix.** - **Indoors with no cellular?** A `country` query usually succeeds anyway on Android (MCC from the serving cell). On iOS the SDK is more dependent on GNSS. Step outside. - **GPS disabled at the OS level?** Re-enable in Settings → Location. - **Mock location app running?** The OS flags every fix as mocked, and the SDK rejects them. Disable mock-location. - **Just launched?** Give it a few seconds. On Android the first MCC-anchored country proof typically lands quickly. On iOS the first GNSS-anchored proof can take 10 to 30 seconds outdoors. --- ### `INDETERMINATE / STALE_FIX` `atTime` falls outside the validity window of any cached proof. **Fix.** - **You're querying a past time.** If you need historical answers, query within a few minutes of when the user was there. The per-resolution windows are: country ±5 min, subdivision ±3 min, city ±1 min, finer shapes ±30 s. - **You're querying near-now.** Wait briefly. The SDK is likely producing a fresh proof on this exact call. If the indeterminate persists, see `NO_FIX` above. --- ### `INDETERMINATE / FUTURE_TIME` `atTime > now + 2 s` (the SDK's clock-skew tolerance). **Fix.** Do not query the future. If you are seeing this with `atTime: Date()` or `Instant.now()` exactly, your device clock is significantly skewed relative to the SDK's internal clock. Re-sync the device clock. --- ### `INDETERMINATE / NO_PROOF_AT_RESOLUTION` The cached proof is **too coarse** for the query. For example, you asked `isWithin(city("San Francisco"))` and the most recent proof is at country level. **Fix.** The SDK should regenerate at the finer level on the next live query. If it persists indoors with no GPS, you may be in an environment where the finer resolution is not provable. Fall back to a coarser predicate (subdivision instead of city) or accept the indeterminate result. --- ### `INDETERMINATE / MOCK_LOCATION_DETECTED` The OS flagged the fix as mocked. The SDK refuses to issue proofs for mocked fixes. **Fix.** Disable any mock-location apps. On Android, also check Developer Options → "Select mock location app" and clear it. --- ### `INDETERMINATE / ATTESTATION_FAILED` Apple App Attest (iOS) or Google Play Integrity (Android) returned a non-compliant verdict. **Fix.** This usually means the device or build environment failed integrity checks: rooted device, custom ROM, modified APK or IPA, debugger attached in a way the attestation provider rejects. The SDK cannot override this. The device or build needs to pass platform integrity. --- ### `INDETERMINATE / SDK_NOT_RUNNING` You called a predicate before `Octet.start(...)` completed, or after the SDK session ended. **Fix.** Hold the `OctetSdk` handle that `start` returned and call predicates on it. Do not call into a nil or disposed reference. --- ### `INDETERMINATE / NOT_YET_RELEASED` The predicate path is declared by the v1 API but not yet wired in this SDK build (e.g. some shape-pair containment combinations). **Fix.** Until the feature lands, fall back to a supported predicate. The combinations that currently produce `NOT_YET_RELEASED` are documented per release in the [`octet-sdk-ios` CHANGELOG](https://github.com/octetproof/octet-sdk-ios/blob/main/CHANGELOG.md) and the [`octet-sdk-android` CHANGELOG](https://github.com/octetproof/octet-sdk-android/blob/main/CHANGELOG.md). --- ## License errors ### `LicenseError.MalformedKey` The PASETO signature didn't verify, the prefix is unknown, the `typ` claim is wrong, or a required claim is missing. **Fix.** Re-copy the key from the signup email. Make sure you copy the entire token including the `octet_live_` or `octet_test_` prefix. --- ### `LicenseError.NoActivation` No cached activation token, and the device is offline. **Fix.** Connect to a network long enough for one successful activation. After that, the SDK works offline for up to 14 days on a cached activation. --- ### `LicenseError.Expired` The cached activation is past the offline grace and the SDK could not reach the backend to refresh. Age alone no longer expires a maintained key. **Fix.** Reconnect so the SDK can refresh the activation. If it persists, contact support. --- ### `LicenseError.ActivationWindowClosed` Not returned under the current maintained-license model. Octet maintains active licenses, so there is no activation window to close. **Fix.** If you hit this, contact support. --- ### `LicenseError.Revoked` The license was revoked by an Octet admin (typical reasons: key was leaked, abuse, payment dispute). **Fix.** Contact Octet support. --- ### `LicenseError.Network` Transient network failure during activation. The associated `message` (iOS) or `cause` (Android) carries the underlying error for diagnostics. Do not parse it for control flow. **Fix.** Retry. If a usable cached activation exists, the SDK never falls into this path on subsequent launches. --- ### `LicenseError.ServerRejected(httpStatus, reason)` Backend rejected for a reason we do not have a dedicated case for (e.g. `app_blocked`, `ip_blocked`). **Fix.** The `reason` tag identifies the policy that fired. Contact support for `app_blocked` or `ip_blocked`. For other tags, check the release notes of the SDK version you are on. --- ## Platform-specific issues ### iOS: app crashes on launch with "privacy-sensitive data" error Missing `Info.plist` key. The error message names the missing key, usually `NSMotionUsageDescription` or `NSLocationWhenInUseUsageDescription`. See [Prerequisites](/getting-started/prerequisites). --- ### iOS: SDK works in foreground but stops generating proofs in background Background-location requires both: ```xml NSLocationAlwaysAndWhenInUseUsageDescription UIBackgroundModes location ``` AND the user must grant `.authorizedAlways` (not just `.authorizedWhenInUse`). The SDK silently falls back to foreground-only if either is missing. --- ### Android: SDK refuses to start with location permission granted in manifest Manifest-level permission is necessary but not sufficient on Android 6 (API 23)+. You must request `ACCESS_FINE_LOCATION` at runtime and wait for the user to grant it before calling `Octet.start(...)`. --- ### Android: motion classification looks degraded `ACTIVITY_RECOGNITION` was likely not granted (Android 10+ / API 29+). The SDK degrades gracefully but with lower confidence. Request the permission at runtime alongside location. --- ### Both platforms: `Octet.start(...)` is slow on first launch Expected on first launch: license signature verification, plus a network round-trip to `/v1/activate`, plus the proof pipeline bring-up. Subsequent launches reuse the cached activation token and skip the network call. If first-launch time matters, call `start` from a coroutine or task on app launch and gate UI on its completion. --- ## Session-binding ### A session-bound proof shows `NOT-CHECKED` for the session binding You called a predicate with a `sessionNonce`, but `octet-verify` reports the session binding as `NOT-CHECKED` instead of checking it. **Fix.** Build `octet-verify` at 1.2.0 or later. An earlier build does not know the session-binding stage, so it skips it and validates the rest of the proof. See [Session-binding](/concepts/session-binding) and the [Verifier Quick Start](/getting-started/verifier-quickstart). --- ## See also - [Verdicts](/concepts/verdicts). Full `ReasonCode` taxonomy. - [License & Activation](/concepts/license-activation). Timeline model and activation flow. - [License Types API Reference](/api-reference/license-types). Exact `LicenseError` cases. - [What's new in 1.2](/whats-new). The API and changes added since 1.1. _Source: https://octetproof.com/docs/troubleshooting/faq/_ --- =================== BROWSER SDK (closed alpha) =================== # Octet Browser SDK The Octet Browser SDK lets your web app ask, for a given session: *what country is the user physically in?* Your backend gets back an answer and a confidence level to act on. :::note[Heads up] This documentation describes the Octet Browser SDK as it ships today. Access uses two keys Octet issues you: a license key the edge presents (`LICENSE`), and a partner key your backend uses to read verdicts (`x-octet-partner-key`). Richer activation is on the roadmap, flagged where relevant (see [Licensing](/reference/licensing)). ::: ## What the SDK does, in one sentence You serve a small **collector** script from your own domain and run a lightweight **edge** binary at your network edge. For each browser session, Octet returns a coarse [verdict](/concepts/verdicts), `{ country, confidence, alarm }`. You decide what to do with it. ## Pick a starting point - **Evaluating the SDK** → [How It Works](/concepts/how-it-works) - **Ready to integrate** → [Quick Start](/getting-started/quickstart) - **Checking what you need first** → [Prerequisites](/getting-started/prerequisites) - **Looking up a package or command** → [Packages & Install](/packages/overview) - **Something broken** → [Troubleshooting & FAQ](/troubleshooting/faq) ## What this documentation covers - [**Getting Started**](/getting-started/prerequisites). What you need, and the end-to-end happy path. - [**Concepts**](/concepts/how-it-works). How the pieces fit together, what a verdict is, what gets collected, and the trust boundary. Plain-English openers, technical depth below. - [**Packages & Install**](/packages/overview). The two artifacts you ship, what each does, and how to get them. - [**Integration Guide**](/integration/embed-collector). Embed the collector, deploy the edge for your server, and read the verdict on your backend. - [**Reference**](/reference/collector-api). The collector API, the verdict schema, edge configuration, and licensing. - [**Troubleshooting**](/troubleshooting/faq). Indexed by what you actually see. ## What the SDK is *not* - **It is not a tracker.** It answers a single question (the likely country of origin for a session) and keeps no per-user state on Octet's side. It does not build user profiles for you. - **It does not tell you *why*.** You get a country and a confidence level. The reasoning that produces them runs on Octet's servers. It is never exposed to your browser or your backend. - **It does not decide for you.** Octet returns a verdict. You apply policy (allow, challenge, log). See [Verdicts](/concepts/verdicts). - **It does not run in the browser alone.** A verdict requires both the in-browser collector and the edge component in front of your app. See [How It Works](/concepts/how-it-works). _Source: https://octetproof.com/docs/browser/_ --- # Prerequisites :::note[In one sentence] You need a modern browser environment, a Linux host at your edge that terminates the browser's connection, and a few credentials Octet issues you. Nothing else. ::: Everything below is a one-time check. If all four boxes are ticked, the [Quick Start](/getting-started/quickstart) takes about fifteen minutes. ## 1. End-user browser The collector runs in your end users' browsers. It targets **ES2020+** and uses only standard, widely-supported Web APIs. There is nothing for your users to install. | Requirement | Why | |---|---| | **HTTPS (secure context)** | The collector and its latency channel run over secure connections only. | | **WebSocket support** | Used for a short latency measurement against your edge. | | **ES2020+ engine** | Any current Chrome, Edge, Firefox, or Safari. Older engines degrade gracefully. | The collector **never requests a permission prompt**. It does not use geolocation, camera, or microphone. Users see nothing. ## 2. A Linux edge host You run the Octet **edge** binary (a small Go service) in front of your web app. There is exactly **one hard infrastructure rule**: :::caution[The one rule: the edge must terminate the browser's connection] The edge reads connection-level network signals (source IP, headers, network timing) that are only accurate when it is the hop that **terminates the browser's TCP connection**. If a TLS-terminating CDN or load balancer sits in front of it, those signals describe that intermediary (not the browser) and confidence drops. See [Deploy the Edge](/integration/deploy-edge) and the per-server guides. ::: | Requirement | Detail | |---|---| | **OS** | Linux (x86-64). The binary is a static `linux/amd64` build. | | **Position** | Connection-terminating hop for browser traffic (see above). | | **Outbound** | Can reach Octet's inference API over HTTPS. (Mutual TLS is planned hardening, not yet required.) | ## 3. Credentials from Octet Octet provisions you with a small set of credentials. These let the edge talk to Octet, and let your backend read verdicts. See [What Octet Provides](/reference/octet-side) for the full list. - A **license key**: supplied to the edge connector (authorizes the edge → Octet channel). [Get one →](/reference/licensing) - A **partner key**: used by your backend to read verdicts (authorizes the backend → Octet channel). - *(Planned)* an **mTLS client certificate + key** and **Octet's CA certificate**, once mutual TLS is enabled for the edge → Octet channel (not required today). - The **Octet API URL**: the edge forwards to it, and your backend reads verdicts from it. ## 4. Your backend You read the verdict with a single **server-to-server** call from your own backend (any language). See [Fetch the Verdict](/integration/fetch-verdict). No SDK is required on the backend. It is one authenticated HTTP request. ## Requirements on Octet's side You do not provision or operate anything on Octet's side. Octet runs the inference API and hands you the credentials above. All verdict computation happens on Octet's infrastructure. For the partner-facing summary of what that entails, see [What Octet Provides](/reference/octet-side). ## Where to go next - [Quick Start](/getting-started/quickstart). The end-to-end happy path. - [How It Works](/concepts/how-it-works). The mental model before you wire anything. _Source: https://octetproof.com/docs/browser/getting-started/prerequisites/_ --- # Quick Start :::note[In one sentence] Serve the collector from your origin, run the edge in front of your app, and read the verdict from your backend. Three moving parts, one verdict. ::: This is the end-to-end happy path. Each step links to its in-depth page. Here we keep it to the minimum that produces a verdict. Make sure you have the [prerequisites](/getting-started/prerequisites) first. ```mermaid flowchart LR A[End-user browser
collector] -->|signals + sessionRef| B[Your edge
octet-edge] B -->|forwards to Octet| C[Octet API] C -->|verdict| B D[Your backend] -->|GET /v1/verdict/:ref| C D -->|allow / challenge / log| E[Your policy] ``` The browser only ever talks to **your** domain. Your backend talks to Octet server-to-server. The browser never contacts Octet directly. See [How It Works](/concepts/how-it-works) for why. ## Step 1: Serve the collector Install the collector ([from GitHub Packages](/packages/collector)) and serve `octet-collector.js` from your own origin, then call `verify()` on page load. Tag the session with an opaque `sessionRef` your backend minted for this page view. ```html ``` `verify()` collects and relays the signals. Do not use its return value for policy (the browser is untrusted, Step 3 is the source of truth). Full options (ESM import, `wsUrl`, `mode`, abort): [Embed the Collector](/integration/embed-collector) and the [Collector API](/reference/collector-api). ## Step 2: Run the edge Put the `octet-edge` binary in front of your app, on the hop that **terminates the browser connection** (see [the one rule](/getting-started/prerequisites#2-a-linux-edge-host)). Point your collector's `apiUrl` at it. ```bash PORT=8080 \ OCTET_URL=https:// \ LICENSE= \ ./octet-edge ``` The edge exposes `POST /v1/signals` (the collector posts here) and `GET /v1/ws` (latency channel). Front it with your existing server so `apiUrl` reaches it. Pick your setup: - [nginx](/integration/edge-nginx) · [Caddy](/integration/edge-caddy) · [Cloud LB / CDN](/integration/edge-cloud-lb) · [Standalone / container](/integration/edge-standalone) ## Step 3: Read the verdict on your backend The authoritative verdict is fetched **server-to-server** by your backend from Octet's API, keyed by the `sessionRef` from Step 1 and authenticated with the **partner key** Octet issues you. Never trust a verdict the browser hands you. Read it yourself. ```bash curl -s "https:///v1/verdict/sess_abc123?waitMs=2000" \ -H "x-octet-partner-key: " # → { "country": "DE", "confidence": 0.91, "alarm": "none" } ``` `waitMs` long-polls (up to 10s) while the browser's collection is still in flight. Then apply **your** policy: allow, step up to a challenge, or just log. Octet decides nothing. Details: [Fetch the Verdict](/integration/fetch-verdict) and the [Verdict Schema](/reference/verdict-schema). ## What you just built ``` browser (your collector) → your edge (octet-edge) → octet → verdict ↓ your backend ← GET /v1/verdict/:ref ↓ your policy ``` ## Where to go next - [How It Works](/concepts/how-it-works). The model behind the three steps. - [Embed the Collector](/integration/embed-collector). Every collector option. - [Deploy the Edge](/integration/deploy-edge). The termination rule, configuration, and your server type. _Source: https://octetproof.com/docs/browser/getting-started/quickstart/_ --- # How It Works :::note[In one sentence] The browser talks only to your domain. A logic-free collector and edge relay signals to Octet, which returns a coarse verdict. The reasoning stays on Octet's servers. ::: ## Structure There are three elements. ```mermaid flowchart LR subgraph Yours [Your infrastructure] A[End-user browser
collector] -->|signals + sessionRef| B[Your edge
octet-edge] D[Your backend] end B -->|forwards to Octet| C[Octet API
the reasoning, server-side only] C -->|verdict cached by sessionRef| C D -->|GET /v1/verdict/:ref| C D -->|allow / challenge / log| E[Your policy] ``` 1. **The collector** runs in the browser. You serve it from your own origin. It reads standard browser signals and measures some network timing, then posts that to **your** edge, never to Octet. See [What Gets Collected](/concepts/signals-overview). 2. **The edge** is a small binary you run in front of your app. It adds a few connection-level observations (the things only the server side can see) and forwards everything to Octet, server-to-server. 3. **Octet** turns those signals into a verdict and hands it back. Your backend reads it [server-to-server](/integration/fetch-verdict) and applies your policy. ## First-party The end-user's browser only ever contacts **your** domain. There is no `octetproof.com` script or request in the page. This keeps Octet invisible upstream and keeps the integration first-party: from the browser's point of view, it is talking to you. Your **backend** does talk to Octet, but server-to-server, out of the browser's sight. That is the only place Octet is contacted directly. ## Logic-free The two pieces you ship (the collector and the edge) are deliberately **logic-free**. They collect, they forward, and that is all. They contain no rules, no thresholds, and no tables. Everything that turns signals into an answer runs **only on Octet's servers**. How that works is not shipped to you, not sent to the browser, and not described in this documentation. You get the verdict, not the method. ```mermaid flowchart TB subgraph Shipped [Shipped to you: logic-free] X[Collector: collect + post] Y[Edge: observe + forward] end subgraph OctetOnly [Octet servers only: never shipped] Z[How a verdict is computed] end X --> Y --> Z ``` ## What this means for you - You integrate two simple relays and read one verdict. - There is nothing on your side that computes the country, so there is nothing to tune, version, or keep in sync. - You own the decision. Octet emits a coarse verdict, `{ country, confidence, alarm }` plus the coarse location output (estimated point and uncertainty region). You decide what to do with it. See [Verdicts](/concepts/verdicts). ## Where to go next - [What Gets Collected](/concepts/signals-overview). The categories of signals, at a high level. - [Verdicts](/concepts/verdicts). What comes back and how to read it. - [Trust & Privacy](/concepts/trust-and-privacy). The boundary, and what Octet never returns. _Source: https://octetproof.com/docs/browser/concepts/how-it-works/_ --- # Verdicts :::note[In one sentence] A verdict is a country, a confidence, an alarm level, and a coarse location region. It is Octet's answer, not a decision. What you do with it is your policy. ::: ## The fields Your backend reads a small, fixed set of fields: the stable, supported surface. | Field | Type | Meaning | |---|---|---| | `country` | ISO 3166-1 alpha-2 string (e.g. `"DE"`) | Octet's estimate of the browser's country of origin for this session. | | `confidence` | number, `0`–`1` | How confident Octet is in that estimate. Higher is more confident. | | `alarm` | `"none"` \| `"low"` \| `"medium"` \| `"high"` | An escalation indicator for this session. Higher means more reason to treat the session cautiously. | | location region | `estimatedLocation` + `confidenceRadiusKm` / `feasibleRegion` | A coarse "where": an estimated point and its uncertainty area (a circle, or a polygon when one was computed). | ```json { "country": "DE", "confidence": 0.91, "alarm": "none", "estimatedLocation": { "lat": 52.52, "lon": 13.40 }, "confidenceRadiusKm": 35 } ``` Full field reference (types, the polygon, optionality): [Verdict Schema](/reference/verdict-schema). ## How to read it - **`confidence`** tells you how much weight to put on `country`. Pick a threshold that fits your risk tolerance. Treat low-confidence verdicts as "not enough signal", not as a negative result. - **`alarm`** is a separate axis from confidence. Use it to decide *how cautious to be* with a session: for example, allow on `none`, log on `low`, step up to a challenge on `medium`/`high`. The exact mapping is yours. ## The browser result is advisory, not authoritative The `verify()` call in the browser resolves when collection finishes, and behind the edge it may carry a coarse result. **Do not build policy on it.** Anything in the browser is client-controlled and can be tampered with. Always read the authoritative verdict on your backend, [server-to-server](/integration/fetch-verdict), keyed by `sessionRef`. ## What a verdict leaves out - **It does not tell you *why*.** You get `country`, `confidence`, and `alarm`, never the signals or reasoning behind them. The reasoning runs on Octet's servers and is never returned, so an attacker can't learn the method and tune around it. - **It is not a decision.** Octet never blocks, challenges, or allows anyone. It reports. You decide. - **It is not a *precise* location.** You get a coarse estimate: a country and an approximate location region (a point with an uncertainty radius or polygon). ## Where to go next - [Verdict Schema](/reference/verdict-schema). Field types and ranges. - [Fetch the Verdict](/integration/fetch-verdict). How your backend reads it. - [Trust & Privacy](/concepts/trust-and-privacy). What is and isn't returned. _Source: https://octetproof.com/docs/browser/concepts/verdicts/_ --- # What Gets Collected :::note[In one sentence] The collector reads standard browser-environment attributes and measures some network timing. The edge observes a few connection-level attributes. Both simply relay them to Octet. ::: ## What the collector reads, by category The collector reads widely-available, non-sensitive browser attributes. At a high level, they fall into a few categories: - **Environment & locale**: for example, the browser's configured language, locale, and time settings. - **Rendering & hardware characteristics**: standard capability and rendering attributes the browser exposes to any page. - **Network timing**: lightweight timing measurements made from the browser. That is the level of detail this documentation goes into on purpose. The specific attributes (and, more importantly, *how they are interpreted*) are part of Octet's reasoning, which runs server-side and is not documented. See [How It Works](/concepts/how-it-works). ## What the edge observes The edge, sitting in front of your app, adds the connection-level observations that only the server side can see: for example the source IP, the request's header ordering, and connection-level network timing. It forwards these alongside the collector's signals. It interprets none of them. ## No prompts, no sensitive permissions The collector is unobtrusive by design: - It **never triggers a permission prompt**. Your users see nothing. - It does **not** use geolocation, the camera, or the microphone. - It runs only in a secure (HTTPS) context. ## Collecting is not interpreting Both the collector and the edge are [logic-free](/concepts/trust-and-privacy). Collecting a signal says nothing about what Octet does with it. The mapping from signals to a `country` and `confidence` lives only on Octet's servers. ## Where to go next - [How It Works](/concepts/how-it-works). The end-to-end model. - [Trust & Privacy](/concepts/trust-and-privacy). The boundary and the data-handling posture. - [Collector API](/reference/collector-api). The exact `verify()` surface. _Source: https://octetproof.com/docs/browser/concepts/signals-overview/_ --- # Trust & Privacy :::note[In one sentence] The pieces you ship are logic-free, Octet keeps no per-user store, and the reasoning behind a verdict is never returned to anyone. ::: ## The trust boundary Think of the system in three tiers, by how exposed each part is: | Tier | What | Where it lives | Exposure | |---|---|---|---| | Shipped | The collector (the set of signals it reads) | The browser, served by you | Logic-free: acceptable to be visible | | In transit | The signal bundle | Passes through your edge | Shows *what* is collected, not *how* it is judged | | Protected | The reasoning that produces a verdict | **Octet servers only** | Never shipped, never returned | The single line that holds the whole model together: the collector and the edge **only collect and forward**. The moment a rule, a threshold, or a lookup table landed in either of them, the method would be in your hands. It never does. See [How It Works](/concepts/how-it-works). ## What Octet never returns Octet returns `country`, `confidence`, and `alarm`. It does not return the signals it weighed, which signals mattered, or reasoning. ## Statelessness and the device key Octet does not keep a per-user store. For each session it computes a verdict and lets it go. If you want returning-visitor continuity, the verdict carries a stable **device key**, an opaque identifier you can persist on your side to recognise a returning browser. Octet does not store it for you. Whether to keep it, and for how long, is your decision and lives under your privacy policy. ## Data-handling posture - The collector reads **non-sensitive** browser attributes and triggers **no permission prompts** (no geolocation, camera, or microphone). See [What Gets Collected](/concepts/signals-overview). - Signals flow first-party (browser → your domain) and then server-to-server (your edge → Octet). The browser never contacts Octet. - Octet is stateless per user. You remain the data controller for your users. Integrate Octet in line with your own privacy obligations. ## Where to go next - [How It Works](/concepts/how-it-works). The architecture this boundary sits in. - [What Gets Collected](/concepts/signals-overview). The categories, at a high level. - [What Octet Provides](/reference/octet-side). What runs on Octet's side. _Source: https://octetproof.com/docs/browser/concepts/trust-and-privacy/_ --- # Packages Overview :::note[In one sentence] You ship two artifacts: the browser collector and the edge binary. The inference API is Octet's, and there is nothing for you to install for it. ::: ## The artifacts | Artifact | What it is | Where it runs | You install it? | |---|---|---|---| | **Collector**: [`@octetproof/collector`](/packages/collector) | A small browser package (ESM + ` ``` The IIFE build attaches a single global, `octet`, exposing `verify()`. :::tip[Serve it first-party] Always serve the collector from **your** origin, not from a third-party URL. This keeps the integration first-party (the browser only talks to you) and lets you pin the SRI hash. See [How It Works](/concepts/how-it-works). ::: ## Where to go next - [Embed the Collector](/integration/embed-collector). The full integration walkthrough. - [Collector API](/reference/collector-api). Every `verify()` option. - [Edge Binary](/packages/edge). The other half of the integration. _Source: https://octetproof.com/docs/browser/packages/collector/_ --- # Edge Binary :::note[In one sentence] `octet-edge` is a small static Linux binary you run at your edge. Use the prebuilt release, or build it from the source Octet provides. ::: The edge is a [logic-free](/concepts/trust-and-privacy) harvester + connector: it observes a few connection-level signals and forwards the bundle to Octet. It holds no rules, tables, or thresholds. ## Option A: Use the prebuilt binary Octet provides `octet-edge` as a static `linux/amd64` binary (a few MB, no runtime dependencies). Place it on your edge host and make it executable: ```bash chmod +x octet-edge-linux-amd64 ./octet-edge-linux-amd64 # see Deploy the Edge for the env it needs ``` ## Option B: Build from source If you'd rather build it yourself (it's a single Go module, **Go 1.26+**), produce the static Linux binary with: ```bash cd apps/edge GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ go build -trimpath -ldflags="-s -w" -o octet-edge-linux-amd64 . ``` You can also build a local binary for development on macOS or Linux (`go build -o octet-edge .`), but note that the connection-level timing signal is Linux-only. A non-Linux build runs for testing but won't produce that signal. ## Requirements - **Linux** (`x86-64`). The binary is built for `linux/amd64`. - The host must **terminate the browser's TCP connection** (the one hard rule). See [Deploy the Edge](/integration/deploy-edge). - Outbound HTTPS to Octet's API. (Mutual TLS is supported and opt-in. See [Edge Configuration](/reference/edge-config).) ## Where to go next - [Deploy the Edge](/integration/deploy-edge). Environment, endpoints, and the termination rule. - Server-specific guides: [nginx](/integration/edge-nginx) · [Caddy](/integration/edge-caddy) · [Cloud LB / CDN](/integration/edge-cloud-lb) · [Standalone / container](/integration/edge-standalone). - [Edge Configuration](/reference/edge-config). The full env-var and endpoint reference. _Source: https://octetproof.com/docs/browser/packages/edge/_ --- # Embed the Collector :::note[In one sentence] Load the collector from your origin, call `verify()` with your edge URL and a session reference, and let it relay signals. Then read the verdict on your backend. ::: This is step 1 of three. You'll also [deploy the edge](/integration/deploy-edge) and [fetch the verdict](/integration/fetch-verdict). ## Mint a `sessionRef` first On each page view where you want a verdict, have your backend mint an opaque, unguessable `sessionRef` (for example a random token) and make it available to the page. It correlates the browser's signal collection with the verdict your backend will later fetch. Keep it short-lived and treat it like a nonce. ## Load and call the collector You serve the collector from **your own origin** (see [Collector package](/packages/collector)). Use whichever load method fits your stack. ### ESM (bundler) ```ts import { verify } from '@octetproof/collector'; await verify({ apiUrl: 'https://yourapp.com/octet', // your edge: see Deploy the Edge sessionRef: 'sess_abc123', // the token your backend minted }); ``` ### ` ``` ## `verify()` options | Option | Type | Notes | |---|---|---| | `apiUrl` | string (required) | Base URL of **your edge**. The collector posts to `${apiUrl}/v1/signals` and opens a latency channel at `${apiUrl}/v1/ws`. | | `sessionRef` | string | The opaque token from your backend. Required if you want to fetch the verdict server-to-server. | | `wsUrl` | string | Override the latency-channel URL. Defaults to the `ws`/`wss` equivalent of `apiUrl` + `/v1/ws`. | | `mode` | `'full' \| 'lite' \| 'passive'` | How much active network collection to do. `full` (default) runs everything. `lite` makes no cross-site probe requests (suits a strict CSP) but keeps VPN confirmation. `passive` does no active measurement at all (lowest confidence). Full detail: [Collection modes](/reference/collector-api#collection-modes). | | `passiveOnly` | boolean | **Deprecated.** Alias for `mode: 'passive'`. Use `mode` instead. | | `signal` | `AbortSignal` | Abort an in-flight `verify()` (e.g. on route change). | Full reference: [Collector API](/reference/collector-api). ## What `verify()` resolves with, and what not to do with it `verify()` returns a `Promise` that resolves when collection finishes. **Do not use its result to make a security decision.** Anything that happens in the browser is client-controlled and can be tampered with. The authoritative verdict comes from your backend, [server-to-server](/integration/fetch-verdict). Treat `verify()` as fire-and-forget from the page's perspective: ```ts // Don't block the user on it; don't trust its return for policy. verify({ apiUrl: 'https://yourapp.com/octet', sessionRef }).catch(() => { // network/collection failed: your backend's verdict fetch will simply be "pending" }); ``` ## Same-origin keeps it simple Serving the collector and exposing the edge under **your** domain (e.g. `yourapp.com/octet`) keeps the whole flow first-party and avoids cross-origin complications. If your edge is on a different subdomain, you'll need CORS to allow it. The edge permits cross-origin calls, but same-origin is the cleaner default. See [How It Works](/concepts/how-it-works). ## Where to go next - [Deploy the Edge](/integration/deploy-edge). Stand up the `apiUrl` the collector posts to. - [Fetch the Verdict](/integration/fetch-verdict). Read the result on your backend. _Source: https://octetproof.com/docs/browser/integration/embed-collector/_ --- # Deploy the Edge :::note[In one sentence] Run `octet-edge` on the Linux hop that terminates the browser connection, give it your credentials, and route the collector's `apiUrl` to it. ::: This is step 2 of three. You should already be [embedding the collector](/integration/embed-collector). Next you'll [fetch the verdict](/integration/fetch-verdict). ## The one rule: terminate the browser connection :::caution[Read this first] The edge reads a **connection-level network-timing** signal that is only meaningful when the edge is the hop where the browser's connection actually terminates. If a separate TLS-terminating CDN or load balancer sits between the browser and the edge, that signal reflects the intermediary, not the browser, and confidence drops. The integration still works without it (the other signals carry the verdict), but you get the strongest result when the edge is the connection-terminating hop. Because this depends on your specific infrastructure, **finalize the exact termination topology with Octet during onboarding**. That's expected and normal. The per-server guides below show the trade-offs. ::: ## Run it The edge is a single static Linux binary ([get it here](/packages/edge)). It's configured entirely through environment variables: ```bash PORT=8080 \ OCTET_URL=https:// \ LICENSE= \ ALLOWED_ORIGIN=https://yourapp.com \ ./octet-edge-linux-amd64 ``` | Variable | Required | Purpose | |---|---|---| | `OCTET_URL` | **yes** | Octet's API base URL. Where the edge forwards. Octet gives you this. **Silently defaults to a dev localhost address if unset. Always set it (see below).** | | `LICENSE` | prod | Your license key, presented to Octet as `x-octet-license`. | | `ALLOWED_ORIGIN` | recommended | Browser origin allowed to call the edge (CORS + the latency WebSocket). Set to your site, e.g. `https://yourapp.com`. Defaults to `*` (any origin). | | `PORT` | – | Port to listen on (default `8080`; use `443` when the edge serves HTTPS directly). | | `EDGE_TLS_CERT_FILE` | recommended | TLS certificate chain (e.g. Let's Encrypt `fullchain.pem`). Set with `EDGE_TLS_KEY_FILE` + `PORT=443` to have the edge serve HTTPS and terminate the browser connection itself (the cleanest way to satisfy the one rule above). Both unset ⇒ plain HTTP. | | `EDGE_TLS_KEY_FILE` | recommended | The matching private key (e.g. `privkey.pem`). Required alongside `EDGE_TLS_CERT_FILE`. | | `EDGE_EXPOSE_VERDICT` | – | Leave off (default). Off ⇒ the browser gets only an `{ ok: true }` ack and your backend fetches the verdict. `1` / `true` exposes the coarse verdict to the browser (demos only). | | `EXIT_IP` | – | Testing only. Overrides the harvested source IP for loopback dev setups. Leave unset in production. | | `EDGE_DEBUG` | – | Diagnostics only. Logs a line per request (incl. the end-user IP) to confirm the edge is the terminating hop. Leave off in production. | | `OCTET_CA_FILE` | optional | Octet's CA certificate. When set, pins and verifies Octet over HTTPS (and anchors mutual TLS). Supported, set it once Octet issues your certs. | | `EDGE_CLIENT_CERT_FILE` | optional | Your client certificate for mutual TLS to Octet. Supported; set it (with the key) to enable mTLS once Octet issues your cert. | | `EDGE_CLIENT_KEY_FILE` | optional | The matching private key for the client certificate above. | :::warning[Always set `OCTET_URL`] If `OCTET_URL` is unset the edge silently falls back to a local dev address (`http://127.0.0.1:8787`). It won't error, but every request fails with `502 octet_unreachable`. Set it to the URL Octet gave you. ::: **Serving HTTPS to the browser.** The browser reaches the edge over HTTPS, so the edge needs TLS in front of it. The strongest setup is to let the edge terminate that TLS itself: set `EDGE_TLS_CERT_FILE` + `EDGE_TLS_KEY_FILE` + `PORT=443` and it serves HTTPS directly, keeping it the connection-terminating hop (the one rule above). The alternative is a reverse proxy that terminates TLS and forwards to the edge, simpler, but that proxy becomes the terminating hop and the timing signal weakens. See [Edge standalone / container](/integration/edge-standalone#serve-https-at-the-edge) for the direct-TLS recipe, or the [nginx](/integration/edge-nginx) / [Caddy](/integration/edge-caddy) guides for the proxy approach. The edge forwards to Octet at `OCTET_URL` (use `https://`). **Mutual TLS is supported:** the `OCTET_CA_FILE` / `EDGE_CLIENT_CERT_FILE` / `EDGE_CLIENT_KEY_FILE` variables are optional and take effect when set. `OCTET_CA_FILE` pins Octet over HTTPS and adding the client cert + key enables mutual TLS. Set them once Octet issues your certs. Full reference: [Edge Configuration](/reference/edge-config). ## Endpoints it exposes | Method | Path | Purpose | |---|---|---| | `GET` | `/health` | Liveness check. Returns `{ ok: true, ... }`. | | `POST` | `/v1/signals` | The collector posts the signal bundle here. The edge forwards and returns a coarse result. | | `GET` | `/v1/ws` | The latency channel the collector measures against. | The collector calls these relative to its `apiUrl`: with `apiUrl: 'https://yourapp.com/octet'`, it posts to `/octet/v1/signals` and connects the WebSocket at `/octet/v1/ws`. Your server routes that prefix to the edge. See the per-server guides. ## Pick your setup The connection-level timing signal aside, the wiring is the same everywhere: route the browser's `apiUrl` traffic (including the WebSocket) to the edge, and pass through the source IP. - [**nginx**](/integration/edge-nginx): reverse-proxy a path prefix to the edge. - [**Caddy**](/integration/edge-caddy): `reverse_proxy` with automatic HTTPS. - [**Cloud LB / CDN**](/integration/edge-cloud-lb): AWS ALB/NLB, GCP, Cloudflare, and the termination caveat that matters most here. - [**Standalone / container**](/integration/edge-standalone): systemd, Docker, or a Kubernetes sidecar. The edge terminates directly. ## Where to go next - [Fetch the Verdict](/integration/fetch-verdict). Read the result on your backend. - [Edge Configuration](/reference/edge-config). Every variable and endpoint. _Source: https://octetproof.com/docs/browser/integration/deploy-edge/_ --- # Edge behind nginx :::note[In one sentence] Reverse-proxy a path prefix (and its WebSocket) to the edge, and pass through the client IP. ::: This routes the collector's `apiUrl` traffic to the edge. It assumes `apiUrl: 'https://yourapp.com/octet'`, so the collector posts to `/octet/v1/signals` and connects the WebSocket at `/octet/v1/ws`. ## Configuration ```nginx # WebSocket upgrade mapping (top-level, in the http {} block) map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { server_name yourapp.com; # ... your existing TLS config (listen 443 ssl; certs; etc.) ... location /octet/ { proxy_pass http://127.0.0.1:8080/; # trailing slash strips the /octet/ prefix proxy_http_version 1.1; # WebSocket support for /octet/v1/ws proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; # The edge reads the source IP from X-Forwarded-For: proxy_set_header X-Forwarded-For $remote_addr; proxy_read_timeout 30s; } } ``` The trailing slash on `proxy_pass http://127.0.0.1:8080/;` maps `/octet/v1/signals` → the edge's `/v1/signals` (and likewise for `/v1/ws`). ## Connection-termination note With this setup, **nginx** terminates the browser's connection and proxies to the edge over the loopback. That means the [connection-level timing signal](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection) is measured at the nginx↔edge hop, not against the browser. The integration still works on the remaining signals. For the strongest result, keep the number of hops in front of this host to a minimum and **confirm the termination topology with Octet during onboarding**. Do not place a separate TLS-terminating CDN/LB in front of nginx without discussing it first. See [Cloud LB / CDN](/integration/edge-cloud-lb). If you want the edge to remain the connection-terminating hop, have it **serve TLS directly** instead of fronting it with nginx. See [Edge standalone / container](/integration/edge-standalone#serve-https-at-the-edge). ## Where to go next - [Deploy the Edge](/integration/deploy-edge). Env vars and the termination rule. - [Fetch the Verdict](/integration/fetch-verdict). Read the result on your backend. _Source: https://octetproof.com/docs/browser/integration/edge-nginx/_ --- # Edge behind Caddy :::note[In one sentence] A `handle_path` block reverse-proxies the Octet prefix to the edge, with automatic HTTPS and WebSocket support out of the box. ::: Caddy is the simplest accurate setup: it provisions Let's Encrypt certificates automatically and proxies WebSockets without extra configuration. This assumes `apiUrl: 'https://yourapp.com/octet'`. ## Caddyfile ```caddy yourapp.com { # ... your existing site config ... handle_path /octet/* { reverse_proxy 127.0.0.1:8080 { header_up X-Forwarded-For {remote_host} } } } ``` `handle_path` strips the matched `/octet` prefix, so `/octet/v1/signals` reaches the edge as `/v1/signals` (and `/octet/v1/ws` as `/v1/ws`). Caddy upgrades the WebSocket automatically. `header_up X-Forwarded-For {remote_host}` gives the edge the source IP. ## Connection-termination note As with any reverse proxy, **Caddy** terminates the browser's connection here, so the [connection-level timing signal](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection) reflects the Caddy↔edge hop rather than the browser. The integration still works on the remaining signals. For the strongest result, minimise the hops in front of this host and **confirm the termination topology with Octet during onboarding**. Avoid putting a separate TLS-terminating CDN/LB in front. See [Cloud LB / CDN](/integration/edge-cloud-lb). If you'd rather keep the edge as the connection-terminating hop, have it **serve TLS directly** instead of fronting it with Caddy. See [Edge standalone / container](/integration/edge-standalone#serve-https-at-the-edge). ## Where to go next - [Deploy the Edge](/integration/deploy-edge). Env vars and the termination rule. - [Fetch the Verdict](/integration/fetch-verdict). Read the result on your backend. _Source: https://octetproof.com/docs/browser/integration/edge-caddy/_ --- # Edge behind a Cloud LB / CDN :::note[In one sentence] Managed load balancers and CDNs often terminate TLS upstream, which weakens the edge's connection-level signal. Preserve termination, or accept reduced confidence. ::: This is the setup that needs the most care. AWS ALB, GCP HTTP(S) LB, and CDNs like Cloudflare or Fastly typically **terminate TLS themselves** and forward a fresh connection to your origin. When they do, the edge sees the load balancer's connection, not the browser's. :::caution[TLS-terminating LBs and CDNs reduce confidence] If a layer-7 / TLS-terminating LB or CDN sits between the browser and the edge, it becomes the connection-terminating hop. The edge's [connection-level timing signal](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection) then reflects that intermediary instead of the browser, and confidence drops. The integration still produces verdicts from the remaining signals, but you lose the strongest one. ::: ## How to preserve termination Pick whichever fits your platform: - **Layer-4 (TCP) pass-through.** Use a network/L4 load balancer that forwards the raw TCP connection instead of terminating TLS, for example an **AWS NLB** (rather than ALB), GCP TCP/SSL proxy in pass-through mode, or Cloudflare **Spectrum**. The browser's connection then reaches the edge host intact. - **Run the edge at the true edge.** Place the edge on the host that first faces the public internet for the Octet path and let it **terminate TLS itself** (`EDGE_TLS_CERT_FILE` / `EDGE_TLS_KEY_FILE` + `PORT=443`), with no TLS-terminating hop in front of it. See [Standalone / container](/integration/edge-standalone#serve-https-at-the-edge). - **A dedicated hostname.** Route a dedicated host (e.g. `octet.yourapp.com`) straight to the edge tier, bypassing the CDN/LB that fronts the rest of your app. Always make sure the real source IP reaches the edge: as the TCP peer in an L4 pass-through, or via `X-Forwarded-For` from an L7 proxy. The edge reads `X-Forwarded-For` first and falls back to the connecting peer. ## If you can't preserve it That's a supported configuration. The verdict still comes back, just with the connection-level signal contributing less. **Discuss your exact topology with Octet during onboarding**. This is precisely the kind of infrastructure detail that's worked out per partner. ## Where to go next - [Deploy the Edge](/integration/deploy-edge). The termination rule in full. - [Standalone / container](/integration/edge-standalone). The termination-preserving setup. _Source: https://octetproof.com/docs/browser/integration/edge-cloud-lb/_ --- # Edge standalone / container :::note[In one sentence] Run the edge directly with systemd, or as a container / Kubernetes sidecar, the setup where it's easiest to make the edge the connection-terminating hop. ::: Running the edge close to where browser traffic lands keeps the [connection-level timing signal](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection) strongest, because there are fewer hops between the browser and the edge. ## Serve HTTPS at the edge In this setup the edge is the front door for the Octet path, so let it **terminate the browser's TLS directly**. That keeps it the connection-terminating hop with no proxy in between. Point it at a certificate for your Octet hostname (e.g. `octet.yourapp.com`) and listen on `443`: | Variable | Value | |---|---| | `EDGE_TLS_CERT_FILE` | Path to the certificate chain (e.g. Let's Encrypt `fullchain.pem`). | | `EDGE_TLS_KEY_FILE` | Path to the matching private key (e.g. `privkey.pem`). | | `PORT` | `443` | Provision the certificate however you already do (Let's Encrypt / certbot, your platform's cert manager, or a cert your ops team issues), and make sure the service user can read both files. With both set the edge serves HTTPS. With them unset it serves plain HTTP, only safe behind a front you've confirmed preserves the browser connection. The examples below use this direct-TLS setup. ## systemd Keep secrets in an environment file (mode `600`), not in the unit: ```ini title="/etc/octet/edge.env" PORT=443 OCTET_URL=https:// LICENSE= # Terminate the browser's TLS at the edge (keeps it the connection-terminating hop): EDGE_TLS_CERT_FILE=/etc/letsencrypt/live/octet.yourapp.com/fullchain.pem EDGE_TLS_KEY_FILE=/etc/letsencrypt/live/octet.yourapp.com/privkey.pem # Mutual TLS to Octet is planned hardening (optional and inert until Octet provisions certs): # OCTET_CA_FILE=/etc/octet/octet-ca.pem # EDGE_CLIENT_CERT_FILE=/etc/octet/edge.crt # EDGE_CLIENT_KEY_FILE=/etc/octet/edge.key ``` ```ini title="/etc/systemd/system/octet-edge.service" [Unit] Description=Octet edge (harvester + connector) After=network.target [Service] ExecStart=/usr/local/bin/octet-edge-linux-amd64 EnvironmentFile=/etc/octet/edge.env Restart=on-failure User=octet DynamicUser=no # Let the non-root service user bind the privileged port 443: AmbientCapabilities=CAP_NET_BIND_SERVICE [Install] WantedBy=multi-user.target ``` The `octet` user must also be able to **read the certificate and key**. Let's Encrypt files under `/etc/letsencrypt/live` are root-only by default, so grant read access (e.g. an ACL or a group) or copy them somewhere the user can read. ```bash sudo systemctl enable --now octet-edge curl -s https://octet.yourapp.com/health # {"ok":true,...} ``` ## Docker The binary is static, so a minimal base image is enough: ```dockerfile FROM gcr.io/distroless/static-debian12 COPY octet-edge-linux-amd64 /octet-edge ENTRYPOINT ["/octet-edge"] ``` ```bash docker run -d --name octet-edge -p 443:443 \ -e PORT=443 \ -e OCTET_URL=https:// \ -e LICENSE= \ -e EDGE_TLS_CERT_FILE=/certs/fullchain.pem \ -e EDGE_TLS_KEY_FILE=/certs/privkey.pem \ -v /etc/letsencrypt/live/octet.yourapp.com:/certs:ro \ octet-edge:latest # To serve plain HTTP behind a connection-preserving front instead, drop the two # EDGE_TLS_* vars and the cert mount, and map -p 8080:8080 with PORT=8080. # Mutual TLS to Octet is planned hardening; when Octet provisions certs, also add: # -e OCTET_CA_FILE=/etc/octet/octet-ca.pem \ # -e EDGE_CLIENT_CERT_FILE=/etc/octet/edge.crt \ # -e EDGE_CLIENT_KEY_FILE=/etc/octet/edge.key \ # -v /etc/octet:/etc/octet:ro ``` ## Kubernetes sidecar Run the edge as a sidecar container in the pod that fronts browser traffic, mounting the credentials from a `Secret`: ```yaml containers: - name: octet-edge image: your-registry/octet-edge:latest ports: - containerPort: 8080 env: - name: OCTET_URL value: https:// envFrom: - secretRef: name: octet-edge-secrets # LICENSE (and the planned mutual-TLS cert paths, when enabled) # Mount the mutual-TLS certs only once Octet provisions them (planned hardening): volumeMounts: - name: octet-certs mountPath: /etc/octet readOnly: true ``` Route the Octet path prefix (or a dedicated hostname) to the sidecar's port `8080`, and make sure the source IP reaches it. :::caution[Ingress TLS termination weakens the signal] If your cluster's Ingress terminates TLS and forwards a fresh connection to the sidecar, the Ingress becomes the connection-terminating hop and the [timing signal](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection) reflects it, not the browser. To keep the edge the terminating hop, either use **TLS passthrough** at the Ingress (route the raw connection to the sidecar) or have the sidecar **serve TLS itself** (mount the cert/key, set `EDGE_TLS_CERT_FILE` / `EDGE_TLS_KEY_FILE`, and expose `443`). See [Cloud LB / CDN](/integration/edge-cloud-lb). ::: ## Where to go next - [Deploy the Edge](/integration/deploy-edge). Env vars and the termination rule. - [Edge Configuration](/reference/edge-config). Full reference. _Source: https://octetproof.com/docs/browser/integration/edge-standalone/_ --- # Fetch the Verdict :::note[In one sentence] Your backend reads the verdict with one authenticated, server-to-server request keyed by `sessionRef`, then applies your policy. ::: This is step 3 of three, after [embedding the collector](/integration/embed-collector) and [deploying the edge](/integration/deploy-edge). ## The request Your backend calls Octet's API directly (server-to-server, out of the browser's sight) using the `sessionRef` you minted in step 1 and the **partner key** Octet issued you: ```bash curl -s "https:///v1/verdict/sess_abc123?waitMs=2000" \ -H "x-octet-partner-key: " ``` | Part | Detail | |---|---| | Method / path | `GET /v1/verdict/{sessionRef}` | | Auth header | `x-octet-partner-key: ` | | `waitMs` (query) | Optional. Long-poll up to this many milliseconds (max 10000) while the browser's collection is still in flight. | ## The response On success you get the coarse verdict for that session: ```json { "country": "DE", "confidence": 0.91, "alarm": "none", "estimatedLocation": { "lat": 52.52, "lon": 13.40 }, "confidenceRadiusKm": 35 } ``` The verdict is `country`, `confidence`, `alarm`, and a coarse **location region** (`estimatedLocation`, `confidenceRadiusKm`, and `feasibleRegion` when a polygon was computed). See the [Verdict Schema](/reference/verdict-schema) for every field. That's the whole response: the reasoning behind it never crosses, so there are no hidden fields. The country and location fields are optional. Handle their absence. If the verdict isn't ready yet (the browser is still collecting, or never did), you get a pending response: ```json { "status": "pending", "ref": "sess_abc123" } ``` with HTTP `404`. Use `waitMs` to wait for it, or poll again shortly. Verdicts are held briefly after collection, so fetch reasonably soon after the page view. ## Examples ```js title="Node (fetch)" const r = await fetch( `https:///v1/verdict/${sessionRef}?waitMs=2000`, { headers: { 'x-octet-partner-key': process.env.OCTET_PARTNER_KEY } }, ); if (r.ok) { const { country, confidence, alarm } = await r.json(); // apply your policy } ``` ```python title="Python (requests)" r = requests.get( f"https:///v1/verdict/{session_ref}", params={"waitMs": 2000}, headers={"x-octet-partner-key": os.environ["OCTET_PARTNER_KEY"]}, ) if r.ok: v = r.json() # {"country": ..., "confidence": ..., "alarm": ...} ``` ## Apply your policy What you do with the verdict is entirely yours. A common shape: ```js if (alarm === 'high') { // step up: challenge, MFA, manual review } else if (confidence >= 0.8 && allowedCountries.includes(country)) { // allow } else { // log / soft-gate / your call } ``` Octet decides nothing. It reports the verdict and you choose. (`country`, `confidence`, and `alarm` are the usual policy drivers. The location region is there when you also want to display or geofence the coarse estimate.) See [Verdicts](/concepts/verdicts) for how to read the fields. ## Never trust the browser The result `verify()` resolves with in the browser is client-controlled. **Always** read the verdict here, on your backend. The `sessionRef` is the only thing that crosses the browser, and it carries no verdict. ## Where to go next - [Verdict Schema](/reference/verdict-schema). Field types and ranges. - [Verdicts](/concepts/verdicts). The conceptual read. - [Licensing](/reference/licensing). Where your keys come from. _Source: https://octetproof.com/docs/browser/integration/fetch-verdict/_ --- # Collector API :::note[In one sentence] The collector's public API is a single function, `verify(config)`, which collects signals and posts them to your edge. ::: ## `verify(config)` ```ts import { verify } from '@octetproof/collector'; await verify(config); ``` Collects the browser signals and posts them to `${config.apiUrl}/v1/signals`, opening a short-lived latency channel at `${config.apiUrl}/v1/ws`. Returns a `Promise` that resolves when collection completes. Designed to run in a **secure (HTTPS) context** and triggers **no permission prompts**. ### `config` | Field | Type | Required | Description | |---|---|---|---| | `apiUrl` | `string` | **yes** | Base URL of your edge. The collector posts to `${apiUrl}/v1/signals` and connects the WebSocket at `${apiUrl}/v1/ws`. | | `sessionRef` | `string` | recommended | Opaque, partner-issued reference for this session. Required if you intend to [fetch the verdict](/integration/fetch-verdict) server-to-server. | | `wsUrl` | `string` | no | Override the latency-channel URL. Defaults to the `ws`/`wss` form of `apiUrl` + `/v1/ws`. | | `mode` | `'full' \| 'lite' \| 'passive'` | no | How much active network collection to do. Defaults to `full`. See [Collection modes](#collection-modes). | | `passiveOnly` | `boolean` | no | **Deprecated.** Kept as an alias for `mode: 'passive'`. Use `mode` instead. If both are set, `mode` wins. | | `signal` | `AbortSignal` | no | Abort an in-flight `verify()` (e.g. on navigation). | ### Collection modes `mode` controls how much active network measurement the collector does on the page. It does not change the passive device signals, which always run. | Mode | What it does | When to use | |---|---|---| | `full` (default) | Runs every measurement, including the cross-site latency probes. | The default. Best accuracy. | | `lite` | Skips the cross-site HTTP probe requests, so the page makes no third-party probe calls (friendlier to a strict Content-Security-Policy and quieter in the network tab), while still running the checks that confirm a VPN/proxy. | Your CSP can't allow third-party probe requests, but you still want VPN confirmation. | | `passive` | No active network measurement at all; relies on device signals only. | Strictest privacy/CSP posture. Lowers confidence: the system can flag a *suspected* VPN but not *confirm* one. | ### Return value The promise resolves to the `SignalBundle` once collection finishes. **Do not use the resolved value to make a security decision.** Anything produced in the browser is client-controlled. Read the authoritative verdict on your backend via [Fetch the Verdict](/integration/fetch-verdict). Treat `verify()` as fire-and-forget from the page's side, and catch errors so a failed collection doesn't surface to the user: ```ts verify({ apiUrl: 'https://yourapp.com/octet', sessionRef }).catch(() => { /* collection failed; the backend verdict fetch will simply be "pending" */ }); ``` ## Where to go next - [Embed the Collector](/integration/embed-collector). The integration walkthrough. - [Verdict Schema](/reference/verdict-schema). What your backend reads. _Source: https://octetproof.com/docs/browser/reference/collector-api/_ --- # Verdict Schema :::note[In one sentence] The verdict your backend reads is a country, a confidence, an alarm level, and a coarse location region. Nothing about how they were derived. ::: ## Success response Returned by [`GET /v1/verdict/{sessionRef}`](/integration/fetch-verdict) once a verdict is ready: ```json { "country": "DE", "confidence": 0.91, "alarm": "none", "estimatedLocation": { "lat": 52.52, "lon": 13.40 }, "confidenceRadiusKm": 35, "feasibleRegion": [ { "lat": 52.70, "lon": 13.05 }, { "lat": 52.30, "lon": 13.10 }, { "lat": 52.35, "lon": 13.80 }, { "lat": 52.68, "lon": 13.75 } ] } ``` | Field | Type | Range / values | Meaning | |---|---|---|---| | `country` | string | ISO 3166-1 alpha-2 (e.g. `"DE"`, `"US"`) | Estimated country of origin for the session. Absent if there wasn't enough signal. | | `confidence` | number | `0`–`1` | Confidence in `country`. Higher is more confident. | | `alarm` | string | `"none"` \| `"low"` \| `"medium"` \| `"high"` | Escalation indicator for the session. Higher warrants more caution. | | `estimatedLocation` | object `{ lat, lon }` | decimal degrees | The estimated location point: the coarse "where". Absent if no location could be estimated. | | `confidenceRadiusKm` | number | kilometres | Radius of the uncertainty circle around `estimatedLocation`. Treat the estimate as an *area*, not a pinpoint. | | `feasibleRegion` | array of `{ lat, lon }` | decimal degrees | Ordered vertices of the uncertainty-region polygon, when one was computed. Absent or empty ⇒ fall back to the `confidenceRadiusKm` circle. | :::note[This is the complete contract] These fields are **everything** that crosses the boundary. Octet projects its full internal assessment down to exactly this shape before any response leaves. There is no hidden "why". The reasoning behind the verdict (which signals were weighed, how the country, alarm, or region were derived) stays on Octet's servers and is never returned, to your backend or the browser. See [Trust & Privacy](/concepts/trust-and-privacy). The `country`, `estimatedLocation`, `confidenceRadiusKm`, and `feasibleRegion` fields are each independently optional. Handle their absence (not enough signal). `confidence` and `alarm` are always present. ::: ## Pending response If the verdict isn't ready (the browser is still collecting, or no signals arrived for that `sessionRef`): ```json { "status": "pending", "ref": "sess_abc123" } ``` returned with HTTP `404`. Use the `waitMs` long-poll, or retry shortly. See [Fetch the Verdict](/integration/fetch-verdict). ## How to interpret it - `confidence` gauges how much to trust `country`; choose a threshold for your risk tolerance and treat low confidence as "not enough signal", not as a negative. - `alarm` is a separate axis: use it to decide how cautious to be, independent of `country`. - **Location region**: `estimatedLocation` is a coarse point. Render or reason about it as an *area*, never a pinpoint. If `feasibleRegion` is present and non-empty, use that polygon; otherwise draw the `confidenceRadiusKm` circle around `estimatedLocation`. Any of the three may be absent when there wasn't enough signal. There is no field telling you *why*. The reasoning stays on Octet's servers so an attacker can't learn the method and tune around it. See [Verdicts](/concepts/verdicts) and [Trust & Privacy](/concepts/trust-and-privacy). ## Where to go next - [Fetch the Verdict](/integration/fetch-verdict). How to read it. - [Verdicts](/concepts/verdicts). The conceptual read. _Source: https://octetproof.com/docs/browser/reference/verdict-schema/_ --- # Edge Configuration :::note[In one sentence] Every environment variable and endpoint of the `octet-edge` binary, in one place. ::: ## Environment variables The edge is configured entirely through the environment. | Variable | Required | Default | Purpose | |---|---|---|---| | `OCTET_URL` | **yes (prod)** | `http://127.0.0.1:8787` *(dev fallback)* | Octet's API base URL the edge forwards to. Octet provides it. **If unset, the edge falls back to a local dev address (see the warning below).** | | `LICENSE` | prod | *(unset)* | Your license key. Sent to Octet as the `x-octet-license` header. Unset → not sent. | | `ALLOWED_ORIGIN` | recommended | `*` *(any origin)* | The browser origin allowed to call the edge (CORS) and open the latency WebSocket. Set to your site's origin in production, e.g. `https://yourapp.com`. Default `*` allows any origin. | | `PORT` | no | `8080` | Port the edge listens on. Set to `443` when the edge serves HTTPS directly (see `EDGE_TLS_CERT_FILE`). | | `EDGE_TLS_CERT_FILE` | recommended | *(unset)* | Path to the TLS certificate chain (e.g. a Let's Encrypt `fullchain.pem`). Set **together with** `EDGE_TLS_KEY_FILE` to have the edge serve HTTPS itself, so it is the hop that terminates the browser's connection (see *Serving HTTPS at the edge* below). Both unset → plain HTTP. | | `EDGE_TLS_KEY_FILE` | recommended | *(unset)* | The matching TLS private key (e.g. `privkey.pem`). Required alongside `EDGE_TLS_CERT_FILE`. Setting only one has no effect. | | `EDGE_EXPOSE_VERDICT` | no | *off* | Whether the edge returns the verdict to the browser. **Leave off in production.** The browser then receives only an `{ "ok": true }` ack and your backend fetches the verdict server-to-server. Set `1` / `true` for demos/testing only. See *Verdict delivery* below. | | `EXIT_IP` | no | *(unset)* | **Testing only.** Overrides the harvested source IP, for local setups where the browser↔edge connection is loopback. Leave unset in production. | | `EDGE_DEBUG` | no | *off* | **Diagnostics only.** Logs one line per request including the end-user's source IP and the connection-level timing observation, useful for confirming the edge is the connection-terminating hop. **Leave off in production** (it logs end-user IPs). Set `1` / `true` to enable. | | `OCTET_CA_FILE` | optional | *(unset)* | Path to Octet's CA certificate. When set, pins and verifies Octet over HTTPS (and anchors mutual TLS). Supported today, set it once Octet issues your certs. | | `EDGE_CLIENT_CERT_FILE` | optional | *(unset)* | Your client certificate for mutual TLS to Octet. Supported, set it (with the key below) to enable mTLS once Octet issues your cert. | | `EDGE_CLIENT_KEY_FILE` | optional | *(unset)* | The matching private key for the client certificate above. | :::warning[Always set `OCTET_URL` in production] If `OCTET_URL` is left unset, the edge **silently falls back to a local development address** (`http://127.0.0.1:8787`). It does not error on startup. Instead every request fails with `502 octet_unreachable`, with no other hint that the variable is missing. Set it to the URL Octet gave you. ::: **Serving HTTPS at the edge (browser → edge).** Set `EDGE_TLS_CERT_FILE` + `EDGE_TLS_KEY_FILE` (e.g. a Let's Encrypt `fullchain.pem` + `privkey.pem`) and `PORT=443` to have the edge terminate the browser's TLS connection itself. This is the cleanest way to satisfy [the one rule](/integration/deploy-edge#the-one-rule-terminate-the-browser-connection): the edge becomes the hop the browser's connection terminates on, so the connection-level timing signal is measured against the browser rather than an intermediary. With both unset the edge serves **plain HTTP**, for development, or when it sits behind a connection-preserving front you've verified (see [Cloud LB / CDN](/integration/edge-cloud-lb)). A standalone TLS recipe is in [Edge standalone / container](/integration/edge-standalone#serve-https-at-the-edge). **TLS to Octet (edge → Octet).** The edge reaches Octet at `OCTET_URL` (use `https://`). **Mutual TLS is supported.** The `OCTET_CA_FILE` / `EDGE_CLIENT_CERT_FILE` / `EDGE_CLIENT_KEY_FILE` variables are optional and take effect when set: `OCTET_CA_FILE` pins and verifies Octet over HTTPS, and adding the client certificate + key enables mutual TLS. Set them once Octet issues your certs. **Verdict delivery.** By default (`EDGE_EXPOSE_VERDICT` off) the browser receives only an opaque `{ "ok": true }` ack, never the verdict. Your backend reads the authoritative result server-to-server with [`GET /v1/verdict/:ref`](/integration/fetch-verdict), keyed by the `sessionRef` you minted, authenticated with your partner key, and applies your policy. Setting `EDGE_EXPOSE_VERDICT=1` returns the coarse `{ country, confidence, alarm }` to the browser instead, convenient for demos, but the browser is untrusted, so keep it off in production. ## Endpoints | Method | Path | Purpose | |---|---|---| | `GET` | `/health` | Liveness check. Returns `{ "ok": true, ... }`. | | `POST` | `/v1/signals` | The collector posts the signal bundle here. The edge forwards it to Octet and returns a coarse result. Requires `Content-Type: application/json`. | | `GET` | `/v1/ws` | Short-lived WebSocket latency channel the collector measures against (the edge closes it after ~15 s). | Paths are relative to wherever you mount the edge. With the collector's `apiUrl: 'https://yourapp.com/octet'`, route `/octet/*` to the edge so `/octet/v1/signals` reaches `/v1/signals`. See the [per-server guides](/integration/deploy-edge#pick-your-setup). ## Notes - The edge is **Linux-only** for the connection-level timing signal. It relies on a capability available only on Linux. A non-Linux build runs for development but omits that signal. - The edge is [logic-free](/concepts/trust-and-privacy). It harvests connection-level signals and forwards. It holds no rules or thresholds. ## Where to go next - [Deploy the Edge](/integration/deploy-edge). The deployment walkthrough. - [Licensing](/reference/licensing). Where `LICENSE` comes from. _Source: https://octetproof.com/docs/browser/reference/edge-config/_ --- # Licensing :::note[In one sentence] Get a license key from Octet, put it in your edge, and Octet verifies it on every forward, plus a separate partner key your backend uses to read verdicts. ::: ## The two credentials Two credentials, both issued by Octet, gate the two channels that reach Octet: | Credential | Used by | How it's sent | Gates | |---|---|---|---| | **License key** | The edge | `LICENSE` env → `x-octet-license` header | The edge → Octet signal channel. | | **Partner key** | Your backend | `x-octet-partner-key` header | The backend → Octet verdict-fetch channel. | Keep both secret and out of any browser-delivered code. Neither ever belongs in the page. The license key is a single **per-deployment** credential: it authorises *your edge*, not individual users or browsers, so there is no per-user activation step. A key is not a meter, and the two are separate concerns: what a key does is authorise, what you are billed for is monthly active users. See [Pricing](https://octetproof.com/pricing). The partner key is issued to you by Octet. See [What Octet Provides](/reference/octet-side). ## Getting a license key Request a browser license key at [sdk.octetproof.com/signup/interest](https://sdk.octetproof.com/signup/interest). The browser SDK's own signup. :::note[Browser keys are specific to the browser SDK] This signup, and the keys it issues, are for the **browser SDK**, a separate flow from the mobile SDK's. The keys are **not interchangeable**: a browser key is scoped to the browser product, so a mobile SDK key won't be accepted here (and a browser key won't work in the mobile SDK). If you use both, keep the two keys distinct. ::: The key is a PASETO v4.public token shaped like `octet_live_v4.public.…`. Put it in your edge's `LICENSE` environment variable. That's all that's required on your side: ```bash LICENSE=octet_live_v4.public.… ./octet-edge-linux-amd64 ``` See [Deploy the Edge](/integration/deploy-edge) for the full edge setup. ## How it's verified Octet verifies the license key **server-side**, on every forward from your edge. There is nothing for you to wire up. A key is accepted only if it: - carries a valid signature from Octet, - is issued for the browser product, - is within its validity window, and - has not been revoked. If a key fails any of these (expired, revoked, malformed, or for the wrong product) Octet rejects the forward and no verdict is produced for that session (see [Troubleshooting](/troubleshooting/faq)). Request a fresh key before yours expires. If a key is ever compromised, Octet can revoke it and the revocation takes effect promptly. :::note How keys are formatted, signed, and validated is internal to Octet. You simply obtain a key and set it. The checklist above is informational, so you know *why* a key might be rejected. ::: ## Where to go next - [What Octet Provides](/reference/octet-side). The full list of what Octet issues you. - [Deploy the Edge](/integration/deploy-edge) · [Fetch the Verdict](/integration/fetch-verdict). - [Troubleshooting](/troubleshooting/faq). What to do when a key is rejected. _Source: https://octetproof.com/docs/browser/reference/licensing/_ --- # What Octet Provides :::note[In one sentence] Octet runs the inference API and issues you the credentials the edge and your backend need. There's nothing for you to operate on Octet's side. ::: ## What runs on Octet's side Octet operates the **inference API**: the service that turns the relayed signals into a verdict. That computation runs entirely on Octet's infrastructure and is never returned to your backend or the browser. See [Trust & Privacy](/concepts/trust-and-privacy). You don't provision, deploy, or operate anything on Octet's side. There are no "requirements on Octet's end" for you to satisfy beyond using the credentials below. ## What Octet issues you Octet provides these credentials. You request the **license key** via the [browser SDK signup](https://sdk.octetproof.com/signup/interest) (browser-specific, keys aren't shared with the mobile SDK). The rest are issued during onboarding: | Item | Used where | For | |---|---|---| | **License key** | The edge (`LICENSE`) | Authorising the edge → Octet channel. | | **Partner key** | Your backend (`x-octet-partner-key`) | Authorising verdict reads. | | **mTLS client certificate + key** *(planned)* | The edge (`EDGE_CLIENT_CERT_FILE` / `EDGE_CLIENT_KEY_FILE`) | Mutual TLS to Octet. Planned hardening, issued when enabled. | | **Octet CA certificate** *(planned)* | The edge (`OCTET_CA_FILE`) | Pinning and verifying Octet for mutual TLS. Planned hardening. | | **Octet API URL** | The edge (`OCTET_URL`) and your backend | The endpoint the edge forwards to and your backend reads from. | > Mutual TLS for the edge → Octet channel (the two *(planned)* rows above) is hardening that is not yet in force. Those certificates are provisioned when it's enabled. Current deployments don't require them. ## What you provide In return, the only infrastructure you run is the two [logic-free](/concepts/trust-and-privacy) pieces: - The [collector](/packages/collector), served from your origin. - The [edge](/packages/edge), on a Linux host that terminates the browser connection. Plus a single [server-to-server verdict read](/integration/fetch-verdict) from your backend. ## Onboarding-time coordination Two things are worked out with Octet per partner, because they depend on your infrastructure: - The exact **connection-termination topology** for the edge (so the connection-level timing signal is preserved). See [Deploy the Edge](/integration/deploy-edge). - Issuance of the credentials above. ## Where to go next - [Prerequisites](/getting-started/prerequisites). The full checklist. - [Licensing](/reference/licensing). The two keys in detail. _Source: https://octetproof.com/docs/browser/reference/octet-side/_ --- # FAQ & Troubleshooting :::note[In one sentence] Indexed by what you actually see. Each entry gives the fix. ::: ## Verdicts ### Confidence is consistently low Most often the edge isn't the connection-terminating hop. A TLS-terminating CDN or load balancer is sitting in front of it, so the connection-level timing signal reflects that intermediary instead of the browser. Preserve termination (see [Cloud LB / CDN](/integration/edge-cloud-lb)) or run the edge [closer to the edge](/integration/edge-standalone). If you can't, that's supported. Confidence is just lower. Coordinate your topology with Octet. ### The verdict fetch returns `{ "status": "pending" }` (HTTP 404) The verdict isn't ready, usually because: - The browser is **still collecting**: add `?waitMs=2000` to long-poll, or fetch slightly later. - The **`sessionRef` doesn't match** the one the collector used. Make sure the same token is minted by your backend, passed to `verify()`, and used in the fetch. - The browser **never reached your edge** (`verify()` failed). Check the browser console and that `apiUrl` resolves to your edge. Verdicts are held only briefly after collection, so fetch reasonably soon after the page view. ## Collector ### `npm install @octetproof/collector` fails with 401 / 403 The package is on GitHub Packages, which requires authentication even for reads. Create a token with the **`read:packages`** scope and configure `.npmrc`: ```ini @octetproof:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} ``` See [Collector package](/packages/collector). ### The WebSocket (latency channel) fails to connect - Make sure the page is served over **HTTPS**. The channel uses `wss`. - If the edge is behind a reverse proxy, confirm the proxy passes the **WebSocket upgrade** headers for `/v1/ws` (see [nginx](/integration/edge-nginx) / [Caddy](/integration/edge-caddy)). - If you set `wsUrl` explicitly, confirm it points at the edge's `/v1/ws`. A failed latency channel isn't fatal (collection continues) but it weakens the result. ### CORS errors in the browser console Serve the collector and expose the edge under the **same origin** as your page (e.g. `yourapp.com/octet`). If the edge is on a different subdomain, allow that origin. See [Embed the Collector](/integration/embed-collector). ## Edge ### Octet rejects the license key (`unlicensed`) Octet rejected the edge's license key. Confirm `LICENSE` is set on the edge and is a current key from Octet. A missing, expired, revoked, malformed, or wrong-product key is rejected. Through the edge this surfaces as an empty verdict (no country). A direct call to Octet returns HTTP 401 `unlicensed`. (In local dev, when no key gate is configured, this check is open.) See [Licensing](/reference/licensing) for how keys are issued and verified. ### The edge returns 502 `octet_unreachable` The edge couldn't reach Octet. Check `OCTET_URL` and outbound network/firewall. (If you've enabled the planned mutual TLS, also confirm `OCTET_CA_FILE`, `EDGE_CLIENT_CERT_FILE`, and `EDGE_CLIENT_KEY_FILE` point at valid files.) See [Edge Configuration](/reference/edge-config). ### The verdict fetch returns 401 `unauthorized` Your backend's `x-octet-partner-key` is missing or wrong. It's a different credential from the edge's license key. See [Licensing](/reference/licensing). ## Still stuck? Reach out to Octet with your `sessionRef` and the timestamps involved. Don't include any keys or certificates in support requests. _Source: https://octetproof.com/docs/browser/troubleshooting/faq/_ ---