{"openapi":"3.1.0","info":{"title":"VerifAI API","version":"1.0.0","description":"Zero-knowledge device trust for any product on the 2Stars platform.\n\nVerifAI lets your backend verify that a login is coming from a device the user has used before, with all signal data hashed on-device — the server only ever sees opaque SHA-256 fingerprints. Use it to:\n\n* Block credential-stuffing attacks where the attacker has the password but not the device.\n* Add a \"new device approval\" flow gated by the user's trusted phone (FCM push).\n* Optionally enforce a same-network policy: approval only fires when the new device shares a public IP with a trusted device.\n\n**Authentication.** Same `hbs_live_…` key as Video AI. Self-service rotation in your dashboard.\n\n**SDKs.** Hand-written for the platforms where browser/OS fingerprinting matters.\n\n* **Web** — `npm install @2stars/verifai-web` ([npmjs.com/package/@2stars/verifai-web](https://www.npmjs.com/package/@2stars/verifai-web) · [github.com/2stars-io/verifai-web](https://github.com/2stars-io/verifai-web)). Pure ESM, no build step. 11 signal categories + 9 behavioral categories.\n* **Android** — Gradle `implementation 'com.github.2stars-io:verifai-android:3.2.0'` via [JitPack](https://jitpack.io/#2stars-io/verifai-android) ([github.com/2stars-io/verifai-android](https://github.com/2stars-io/verifai-android)). Same API surface as Web plus motion-tremor, contact-lock, and FCM push approval.\n* **Umbrella JS** — `npm install @2stars/sdk` pulls VerifAI + Video AI + React in one shot.\n* **Backend (any language)** — generate a client from this spec via `openapi-generator-cli`.\n\n**Same-network gate.** Optional opt-in. Toggle from the dashboard or via `PATCH /verifai/v1/config`.\n\n**Feature toggles.** VerifAI is split into three independent axes so a customer can adopt each separately. Read your current state from `GET /verifai/v1/config`; flip them from your admin dashboard.\n\n* `verifai-patterns` — gates **device-pattern collection**. When ON, the server stores hashed signal payloads sent on `POST /verifai/v1/registerDevice` and the signal portion of `POST /verifai/v1/verifyDevice`. Use this if you want to capture device fingerprints for analytics or downstream comparison. With `patterns` OFF, signal payloads are silently dropped and `registerDevice` returns `403 feature_disabled`.\n* `verifai-verification` — gates the **login-decision flow**. When ON, `verifyDevice` returns one of `TRUSTED` / `NEW_DEVICE` / `PENDING` / `REJECTED` and may open an approval session. With `verification` OFF, `verifyDevice` and the approval/reject pair return `403 feature_disabled`.\n* `verifai-behavioral` — gates **behavioral biometrics**. When ON, the SDK ships a separate `behavioralHashes` payload alongside the device fingerprints, capturing how the user *interacts* with the device. 3.3.0+ covers ~21 categories across seven independent biometric **families** — keystroke (flight, stddev, bigram, per-character vector), dwell (key-hold duration), mouse (velocity, curvature, region), click rhythm, scroll (velocity, direction-changes), nav (tab-vs-click), pace (page-dwell), plus Android-only touch (swipe, tap, multi-touch, region) and motion (accel + gyro tremor). The server auto-trains a per-device baseline over the first **5** verifies (tunable to 3-50 via `trainingLogins` in `PATCH /verifai/v1/config`), then locks it. After lock, every verify includes a `behavioral` outcome (`training` / `match` / `mismatch`). 3.3.0's diversity gate requires hashes from at least 2 high-entropy families before granting `match` — a session that only ships keystroke-family hashes returns `mismatch` with `mismatches: [\"insufficient_family_coverage\"]` to defend against shoulder-surfers on shared hardware.\n* `verifai-behavioral-block` — companion flag controlling **what happens on a behavioral mismatch**. When OFF (default), `mismatch` is informational: `status` stays at the fingerprint verdict (typically `TRUSTED`) and your app reads `behavioral.status` as a soft signal. When ON, `mismatch` overrides `status` to `REJECTED` with `reason: 'behavioral_mismatch'` — a hard reject without an approval session, because the typical scenario isn't \"new device\" but \"different person\", and approving on a second device wouldn't resolve it.\n* `verifai-strict-block` (3.1.0+) — once the device has completed `trainingLogins` successful verifies (`strictMode.state` flips from `training` to `armed`), any anomaly (signal drift ≥ threshold OR any server-side pattern anomaly) forces a `REJECTED` with `reason: 'strict_block:<anomalies>'` instead of just demoting trust. Default OFF — turn on for high-security accounts.\n\n### v3.2.0+ controls\n\n* `verifai-pattern-{touch,motion,keystroke,network}` — four group-level admin gates over behavioral signals. The dev independently opts in or out of each via `enabledGroups` in `PATCH /verifai/v1/config`. A category is compared only when BOTH the admin allows the group AND the dev enabled it.\n* `verifai-advanced-patterns` — admin gate for the **30-day day-of-week + hour-bucket baseline**. When admin-allowed AND the dev sets `advancedPatternsDevOptIn: true`, every verify response carries an `advanced` block comparing the login to other logins on the same weekday + hour over the last 30 days. Catches \"user always banks on Sunday morning, this Tuesday-3am attempt looks nothing like the bucket.\"\n* `verifai-contact-lock` (Android only) — admin gate for the contact-overlap matcher. When the host app flips `VerifAI.setContactLockEnabled(true)` AND the device has `READ_CONTACTS`, the SDK uploads a salted-hash fingerprint of phone contacts. Server returns `contacts.overlapPct` against the stored baseline. **Raw phone numbers never leave the device.** The dev's threshold (default 80%) is set via `contactLockThreshold` in `PATCH /verifai/v1/config`.\n\n### Trust score\n\nEvery `/verifyDevice` response in 3.2.0+ carries `trustScore` (0-100, unweighted mean of the per-axis scores in `scoreBreakdown`) and `scoreBreakdown` itself (one entry per axis that actually ran: `device`, `behavioral`, `advanced`, `patterns`, `contacts`). **You decide what to do with it.** Block on login, require step-up auth for high-value transfers, log for fraud analysis — whatever fits your product. The strict-block flag above is one ready-made policy, but most customers prefer to consume the score and decide themselves.\n\nA common adoption path is **patterns first → verification second → behavioral last**: collect device-fingerprint data for a few weeks, enable verification once you have a baseline, then layer behavioral on for high-stakes flows (account opening, large transfers, admin consoles). Each toggle can run alone; they're independent.\n\nWhen a request hits an endpoint gated by a disabled feature, the server returns HTTP 403 with `error.code = \"feature_disabled\"` and `error.featureName` naming the specific toggle. The Android SDK surfaces this as `Status.FEATURE_DISABLED` so your app can show a clean \"enable VerifAI in your dashboard\" message instead of a generic error.","contact":{"name":"2Stars","url":"https://2stars.io","email":"hello@2stars.io"},"license":{"name":"Proprietary","identifier":"LicenseRef-Proprietary"}},"servers":[{"url":"https://api.2stars.io","description":"Production"},{"url":"http://localhost:8080","description":"Local dev — note: until DNS rewrite lands, paths are served at /api/v1/* under this host. Replace /video/v1/* with /api/v1/* and /verifai/v1/* with /api/v1/verifai/* when calling the local docker stack."}],"components":{"securitySchemes":{"ApiKey":{"type":"http","scheme":"bearer","bearerFormat":"hbs_live_…","description":"Your live API key. Format `hbs_live_<base64url>`. Test environments may use `hbs_test_<base64url>`. Self-service rotation in the developer dashboard at /dashboard."},"Dashboard":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Dashboard JWT minted by `POST /auth/login`. Used only by the customer console (your dashboard) for self-service operations on the developer's own account. Never ship in client apps."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code","example":"invalid_request"},"message":{"type":"string","description":"Human-readable error message"}}}}},"VerifAIDevice":{"type":"object","required":["id","type","deviceId","loginCount","firstSeen","lastSeen"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["android","pc","ios","web"]},"deviceId":{"type":"string","description":"Truncated SHA-256 of the raw device signals (ANDROID_ID + Build.MODEL + Build.BRAND on Android). Pre-3.4.1 SDKs also mixed in a per-install salt for cross-app correlation privacy; 3.4.1+ relies on the per-app-signing-cert partitioning of ANDROID_ID that Android 8+ provides at the OS level, which delivers the same property without the Samsung One UI 7 Keystore-wipe instability we saw on the 2026-06-13 dkisos / Galaxy S25 Ultra report."},"name":{"type":"string","nullable":true},"brand":{"type":"string","nullable":true},"model":{"type":"string","nullable":true},"loginCount":{"type":"integer","minimum":0},"lastCountry":{"type":"string","nullable":true},"firstSeen":{"type":"string","format":"date-time"},"lastSeen":{"type":"string","format":"date-time"}}},"VerifAISignalHashes":{"type":"object","description":"Hashed device-fingerprint categories. Each value is a hex SHA-256 of the raw signal. Raw signals never leave the device — the server only ever sees opaque hashes. Cross-app unlinkability: SDKs ≤3.4.0 mixed a per-install salt into every hash so the same physical device produced different fingerprints across two unrelated apps. SDK 3.4.1+ on Android dropped the salt because Settings.Secure.ANDROID_ID is already partitioned per app-signing-cert on Android 8+ (≈99% of in-the-wild devices), which delivers the same per-app unlinkability without the Samsung One UI 7 Keystore-wipe instability the salt-based path was introducing. Web and iOS SDKs still salt; semantics are unchanged. New categories may be added by future SDK versions; servers ignore unknown keys, so additionalProperties is allowed.","properties":{"androidIdHash":{"type":"string"},"deviceHash":{"type":"string"},"integrityHash":{"type":"string"},"localeHash":{"type":"string"},"carrierHash":{"type":"string"},"keyboardHash":{"type":"string"},"defaultAppsHash":{"type":"string"},"networkClassHash":{"type":"string","description":"3.1.0+: coarse network class label, hashed. Replaced `appsHash` which degraded on Android 11+ without QUERY_ALL_PACKAGES."},"personalizationHash":{"type":"string"},"networkHash":{"type":"string"},"usageHash":{"type":"string"},"playIntegrityHash":{"type":"string","description":"Legacy field — ignored server-side. Send `playIntegrityToken` instead."}},"additionalProperties":true},"VerifAIBehavioralHashes":{"type":"object","description":"Bucket-hash payload from the SDK's on-device behavioral collector. Each property is the SHA-256 of a coarse bucket label — on web and iOS the bucket label is salted with a per-install device-local salt; on Android (SDK 3.4.1+) the device-fingerprint collectors dropped their salt but the behavioral collectors' bucket hashes are unaffected by that change. Server never sees the raw averages — only the bucket hash. All fields are optional: the SDK omits a category when the user hasn't produced enough samples in the current session (e.g. no swipes happened, so `swipeVelocity` is omitted). Categories are grouped into independent **biometric families** for the diversity gate (introduced 3.3.0): a session must supply hashes from at least two high-entropy families before a `match` verdict can be returned — keystroke, keystrokeStddev, keystrokeBigram, keystrokeVector all count as ONE family (correlated). Web 3.3.0+ adds dwell + per-character vector + page-dwell as new orthogonal signals to defend against shoulder-surfers on shared hardware.","properties":{"swipeVelocity":{"type":"string","description":"Hashed bucket of avg finger-move velocity in px/ms over the session's swipes."},"tapPressure":{"type":"string","description":"Hashed bucket of avg MotionEvent.size — proxy for finger size + pressure."},"buttonRhythm":{"type":"string","description":"Hashed bucket of avg ms between consecutive ACTION_UP events."},"swipeCurvature":{"type":"string","description":"3.1.0+: hashed bucket of swipe path curvature (straight vs arcing)."},"multiTouchSpread":{"type":"string","description":"3.1.0+: hashed bucket of finger-spread distance during multi-touch gestures."},"touchRegion":{"type":"string","description":"3.1.0+: hashed top-3 cells of a 5×5 screen grid — reveals grip orientation (thumb vs index, left vs right)."},"motionTremor":{"type":"string","description":"Hashed bucket of accelerometer spectral signature on the login screen."},"gyroTremor":{"type":"string","description":"3.1.0+: hashed bucket of gyroscope spectral signature — how the device tilts while held."},"keystroke":{"type":"string","description":"Hashed bucket of mean inter-character flight time on password entry."},"keystrokeStddev":{"type":"string","description":"3.1.0+: hashed bucket of stddev across inter-character flight times."},"keystrokeBigram":{"type":"string","description":"3.1.0+: hashed bucket of per-bigram-class timing (character-class pairs, never the actual characters)."},"keystrokeDwell":{"type":"string","description":"3.3.0+ (web): hashed bucket of median key-hold duration (keydown→keyup). New **dwell** family — independent of flight time; measures finger anatomy + force."},"keystrokeVector":{"type":"string","description":"3.3.0+ (web): hashed coarse-bucketed flight-time sequence for the focused tracked field (password). Hash of a string like \"fmsmf\" so a shoulder-surfer typing the same password produces a different per-char pattern."},"mouseVelocity":{"type":"string","description":"3.2.0+ (web): hashed bucket of avg cursor velocity between clicks."},"mouseCurvature":{"type":"string","description":"3.2.0+ (web): hashed bucket of cursor-path straightness ratio."},"clickRhythm":{"type":"string","description":"3.2.0+ (web): hashed bucket of avg ms between clicks."},"cursorRegion":{"type":"string","description":"3.2.0+ (web): hashed top-3 cells of a 10×10 viewport heatmap."},"scrollVelocity":{"type":"string","description":"3.2.0+ (web): hashed bucket of avg scroll velocity in px/ms."},"scrollDirChanges":{"type":"string","description":"3.2.0+ (web): hashed bucket of direction-change count during scroll."},"fieldNavigation":{"type":"string","description":"3.2.0+ (web): hashed bucket of tab-vs-click ratio between form fields. Treated as a low-entropy family — does NOT count toward the 3.3.0 diversity gate."},"pageDwell":{"type":"string","description":"3.3.0+ (web): hashed bucket of total ms from listeners-installed → verify call. New **pace** family — catches hesitation vs autofill."}},"additionalProperties":true},"VerifAIBehavioralReport":{"type":"object","description":"Outcome of the behavioral biometrics check. Present on register/verify responses when `verifai-behavioral` is ON for the API key. Sliding-window model: first **5** successful logins (down from 10 in 3.3.0) train the baseline, after which every login does a K-of-N match against the locked window. In 3.3.0+ the comparison enforces two rules: (a) **proportional tolerance** — `floor(compared / 5)` mismatches allowed, not an absolute slack; (b) **family-diversity gate** — the session must supply hashes from at least 2 high-entropy biometric families (keystroke, dwell, mouse, click, scroll, touch, motion), otherwise `status=\"mismatch\"` with `mismatches: [\"insufficient_family_coverage\"]` regardless of per-category match. In 3.4.0+ the per-key feature flag `verifai-behavioral-quorum` swaps total-mismatch tolerance for a **family-quorum** rule: per-family majority-match, then require ≥ `quorumRequired` high-entropy families to pass. This exploits the asymmetry that a different human fails entire families at once (e.g. all of `touch` because their finger pressure pattern differs) while the genuine user on a noisy day only wobbles in one or two categories of one family. The `rescued` flag is set when the device row was silently migrated to a new `deviceIdHash` via the `verifai-behavioral-rescue` path (the user got a new salt — `pm clear`, app reinstall — but behavioral still matched).","properties":{"status":{"type":"string","enum":["training","match","mismatch"],"description":"training: baseline still building. match: ≤ tolerance categories mismatched OR ≥ quorumRequired families passed. mismatch: above tolerance / below quorum / insufficient family coverage."},"loginCount":{"type":"integer","minimum":0,"description":"Behavioral training sessions recorded so far for this device."},"remainingTraining":{"type":"integer","minimum":0,"description":"Sessions remaining before the baseline locks. 0 once locked."},"mismatches":{"type":"array","items":{"type":"string"},"description":"Category names that failed the bucket check on a `mismatch` outcome. The sentinel value `insufficient_family_coverage` (3.3.0+) is prepended when the diversity gate tripped."},"compared":{"type":"integer","minimum":0,"description":"3.1.0+: how many categories had data on both sides for comparison (diagnostic)."},"tolerance":{"type":"integer","minimum":0,"description":"3.1.0+: how many mismatches were allowed before flagging as mismatch. 0 in `quorum` mode (tolerance is per-family, not global)."},"familiesCovered":{"type":"array","items":{"type":"string"},"description":"3.3.0+: distinct biometric families with comparable data this session (e.g. [\"keystroke\",\"mouse\",\"click\"]). Used by the diversity gate."},"mode":{"type":"string","enum":["tolerance","quorum"],"description":"3.4.0+: which comparison mode produced this verdict. `tolerance` is the legacy total-mismatch slack; `quorum` is per-family majority-match (enabled per-key via `verifai-behavioral-quorum`)."},"familyVerdicts":{"type":"object","additionalProperties":{"type":"string","enum":["match","mismatch","excluded"]},"description":"3.4.0+ (quorum mode only): per-family verdict. `excluded` = low-entropy family (e.g. nav) that doesn't count toward the quorum."},"familiesPassing":{"type":"integer","minimum":0,"description":"3.4.0+ (quorum mode only): count of high-entropy families that majority-matched."},"quorumRequired":{"type":"integer","minimum":1,"description":"3.4.0+ (quorum mode only): minimum families that must majority-match for `status=match`. Defaults to 2; admin-tunable via `VERIFAI_BEHAVIORAL_FAMILY_QUORUM`."},"rescued":{"type":"boolean","description":"3.4.0+: true when this verify took the behavioral-rescue path — the incoming `deviceIdHash` was unknown but behavioral matched a single prior trained device, so the stored hash was silently migrated rather than opening a cross-device approval session. Only set on rescued logins."}}},"VerifAIAdvancedReport":{"type":"object","description":"3.2.0+: Outcome of the advanced-patterns check (30-day rolling baseline keyed by day-of-week + hour-bucket). Present only when BOTH `verifai-advanced-patterns` is admin-allowed AND the developer has opted in via `advancedPatternsDevOptIn`.","properties":{"status":{"type":"string","enum":["learning","match","mismatch"],"description":"learning: bucket has < minimum samples to compare yet. match: matchRatio ≥ threshold. mismatch: below."},"bucket":{"type":"string","example":"Mon-9","description":"Bucket key the server compared against (`${DAY}-${hourBucket}`)."},"bucketSampleCount":{"type":"integer","minimum":0,"description":"Prior samples in the bucket the current login was compared against."},"matchRatio":{"type":"number","minimum":0,"maximum":1,"description":"Fraction of bucket samples that strongly matched the current login (≥ 0.5 jaccard). Only meaningful when status ≠ learning."},"threshold":{"type":"number","minimum":0,"maximum":1,"description":"Bucket-match threshold (matchRatio ≥ threshold ⇒ match). Default 0.5."}}},"VerifAIContactsReport":{"type":"object","description":"3.2.0+: Outcome of the contact-lock check. Present only when `verifai-contact-lock` is enabled AND the SDK uploaded a non-empty `contactHashes` array. Server stores the first set as the baseline, then on each subsequent call returns the overlap percentage. The host app decides what to do with the value — we never auto-block.","properties":{"status":{"type":"string","enum":["baseline","match","mismatch"],"description":"baseline: server just persisted the set (first ever scan). match: overlapPct ≥ developer's configured threshold. mismatch: below."},"overlapPct":{"type":"integer","minimum":0,"maximum":100,"nullable":true,"description":"|stored ∩ incoming| / |stored| as a percentage. Null on baseline."},"storedCount":{"type":"integer","minimum":0,"description":"How many contact hashes are on file for this device."},"incomingCount":{"type":"integer","minimum":0,"description":"How many contact hashes were uploaded in this request."},"intersectCount":{"type":"integer","minimum":0,"description":"Size of the intersection (only set on match/mismatch)."},"threshold":{"type":"integer","minimum":0,"maximum":100,"description":"Developer's configured match threshold (default 80%)."}}},"VerifAIPatternsReport":{"type":"object","description":"3.1.0+: Server-side pattern-anomaly analysis. Combines four detectors that run on every successful verify: re-registration cadence, multi-user device, long absence, and impossible travel. Always present on TRUSTED responses (may be null on failure). Demotes trust by up to 20 points; never promotes.","properties":{"riskLevel":{"type":"string","enum":["low","medium","high","critical"]},"trustDelta":{"type":"integer","maximum":0,"description":"Score adjustment applied to base trust. Always ≤ 0."},"anomalies":{"type":"array","items":{"type":"string"},"description":"Short tag strings describing each fired detector (e.g. `impossible_travel:9560km_in_5min_114720kmh`)."},"reRegistration":{"type":"object","additionalProperties":true},"multiUserDevice":{"type":"object","additionalProperties":true},"longAbsence":{"type":"object","additionalProperties":true},"impossibleTravel":{"type":"object","additionalProperties":true}}},"VerifAIStrictModeReport":{"type":"object","description":"3.1.0+: Strict-block mode state. Non-null only when `verifai-strict-block` is enabled. Once the device has completed `trainingTotal` successful verifies, the state flips from TRAINING to ARMED — any anomaly (signal drift ≥ threshold OR any analyzePatterns anomaly) then forces REJECTED instead of demoting trust.","properties":{"state":{"type":"string","enum":["training","armed"]},"loginCount":{"type":"integer","minimum":0},"trainingTotal":{"type":"integer","minimum":1,"default":10},"remaining":{"type":"integer","minimum":0,"description":"trainingTotal - loginCount, clamped to 0."},"triggered":{"type":"array","items":{"type":"string"},"description":"Anomaly tags that caused this REJECTED, if any."}}},"VerifAIScoreBreakdown":{"type":"object","description":"3.2.0+: Per-axis 0-100 trust score breakdown. Keys present mirror which axes actually ran for this call (axes that didn't run are excluded from both the breakdown AND the aggregate). The aggregate is `trustScore` on the parent response — it's the unweighted mean of the axis scores.","additionalProperties":{"type":"integer","minimum":0,"maximum":100},"properties":{"device":{"type":"integer","minimum":0,"maximum":100,"description":"Device-fingerprint compare score (hard-match + soft-drift)."},"behavioral":{"type":"integer","minimum":0,"maximum":100,"description":"Behavioral-biometrics compare score (100 on match, 30 on mismatch)."},"advanced":{"type":"integer","minimum":0,"maximum":100,"description":"Advanced-patterns compare score (100 on match, scaled from matchRatio on mismatch)."},"patterns":{"type":"integer","minimum":0,"maximum":100,"description":"Server-side analyzePatterns score (80 + trustDelta, clamped 0-100)."},"contacts":{"type":"integer","minimum":0,"maximum":100,"description":"Contact-lock overlap percentage (verbatim)."}}},"VerifAIVerifyRequest":{"type":"object","required":["userId","deviceIdHash","signalHashes"],"properties":{"userId":{"type":"string","maxLength":256},"deviceIdHash":{"type":"string","minLength":16,"maxLength":256},"deviceType":{"type":"string","enum":["android","pc","ios","web"],"default":"android"},"signalHashes":{"$ref":"#/components/schemas/VerifAISignalHashes"},"behavioralHashes":{"$ref":"#/components/schemas/VerifAIBehavioralHashes"},"contactHashes":{"type":"array","items":{"type":"string","pattern":"^[0-9a-f]{16,64}$"},"maxItems":5000,"description":"3.2.0+: Optional. Hex-encoded SHA-256 hashes of the user's phone contacts, salted with a per-developer secret and truncated to 16-32 bytes. Sent only when the host app has flipped `VerifAI.setContactLockEnabled(true)` AND the OS-level READ_CONTACTS permission is granted. Server stores the first set as the baseline; subsequent calls return `contacts.overlapPct` against it. The server never sees raw phone numbers."},"playIntegrityToken":{"type":"string","nullable":true,"description":"Raw Play Integrity token (Android only). Server decodes and stores the verdict."},"publicIP":{"type":"string","nullable":true,"description":"Required for the same-network gate."},"deviceModel":{"type":"string","nullable":true},"deviceBrand":{"type":"string","nullable":true},"sdkVersion":{"type":"string","nullable":true,"description":"3.1.0+: SDK build version, sent as a metadata field."},"hostPackageName":{"type":"string","nullable":true,"description":"3.1.0+: Host app's package name (Android) / bundle identifier (iOS)."},"hostAppVersion":{"type":"string","nullable":true,"description":"3.1.0+: Host app's `versionName`. Server can flag rollback / cloned-APK situations."},"hostAppVersionCode":{"type":"integer","nullable":true,"description":"3.1.0+: Host app's `versionCode`."}}},"VerifAIVerifyResponse":{"type":"object","required":["status","deviceId","trustLevel","trustScore"],"properties":{"status":{"type":"string","enum":["TRUSTED","NEW_DEVICE","PENDING","REJECTED","FEATURE_DISABLED"]},"sessionId":{"type":"string","format":"uuid","nullable":true,"description":"Set on NEW_DEVICE — client polls or shows approval UI"},"deviceId":{"type":"string","description":"Truncated SHA-256 of this device"},"trustLevel":{"type":"string","enum":["BASELINE","MEDIUM","HIGH","VERY_HIGH"]},"trustScore":{"type":"integer","minimum":0,"maximum":100,"description":"3.2.0+: Composite 0–100 trust score, the unweighted mean of the axes in `scoreBreakdown`. On TRUSTED responses this is the canonical number to consume for policy decisions; on REJECTED responses (e.g. behavioral_mismatch, strict_block) it's 0."},"scoreBreakdown":{"$ref":"#/components/schemas/VerifAIScoreBreakdown"},"loginCount":{"type":"integer","minimum":0,"nullable":true},"reason":{"type":"string","nullable":true,"description":"When status≠TRUSTED OR when an unusual TRUSTED path was taken. Examples: `new_device`, `hard_mismatch`, `integrity_regression`, `different_network`, `signal_drift`, `behavioral_mismatch`, `strict_block:<anomalies>`, `behavioral_rescue` (3.4.0+: returned with status=TRUSTED when the device row was silently migrated to a new deviceIdHash via the behavioral-rescue path — see `verifai-behavioral-rescue` feature flag)."},"drift":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Soft signal categories that drifted since last verify"},"demoted":{"type":"boolean","description":"3.1.0+: true when heavy soft-signal drift demoted the device a trust tier."},"integrity":{"type":"object","nullable":true,"properties":{"passed":{"type":"boolean","nullable":true},"strong":{"type":"boolean","nullable":true},"basic":{"type":"boolean","nullable":true},"app":{"type":"boolean","nullable":true}}},"behavioral":{"$ref":"#/components/schemas/VerifAIBehavioralReport"},"patterns":{"allOf":[{"$ref":"#/components/schemas/VerifAIPatternsReport"}],"nullable":true},"strictMode":{"allOf":[{"$ref":"#/components/schemas/VerifAIStrictModeReport"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/VerifAIAdvancedReport"}],"nullable":true,"description":"3.2.0+: present when admin + dev both enabled advanced patterns."},"contacts":{"allOf":[{"$ref":"#/components/schemas/VerifAIContactsReport"}],"nullable":true,"description":"3.2.0+: present when contact-lock is enabled AND the SDK uploaded a non-empty `contactHashes` array."}}},"VerifAIRegisterResponse":{"type":"object","required":["success","deviceId"],"properties":{"success":{"type":"boolean"},"deviceId":{"type":"string"},"masterHash":{"type":"string","nullable":true},"loginCount":{"type":"integer","minimum":0,"nullable":true},"integrity":{"$ref":"#/components/schemas/VerifAIVerifyResponse/properties/integrity"},"behavioral":{"$ref":"#/components/schemas/VerifAIBehavioralReport"},"contacts":{"allOf":[{"$ref":"#/components/schemas/VerifAIContactsReport"}],"nullable":true,"description":"3.2.0+: present when contact-lock is enabled AND the SDK uploaded a non-empty `contactHashes` array on this register call."}}},"VerifAITrustScore":{"type":"object","required":["trustLevel","score","loginCount","ageInDays"],"properties":{"trustLevel":{"type":"string","enum":["BASELINE","MEDIUM","HIGH","VERY_HIGH"]},"score":{"type":"integer","minimum":0,"maximum":100},"loginCount":{"type":"integer","minimum":0},"ageInDays":{"type":"integer","minimum":0}}}},"responses":{"BadRequest":{"description":"Malformed request — see `error.code` and `error.message`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Feature disabled on this API key (e.g. `verifai-verification` toggled off).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource does not exist or is not visible to this key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"tags":[{"name":"Device verification","description":"Customer-facing endpoints for register/verify/trust scoring."},{"name":"Cross-device approval","description":"Approve / reject pending sessions from a primary device."},{"name":"Configuration","description":"Self-service settings (same-network gate, etc.)."},{"name":"Developer console","description":"Endpoints used by your dashboard. Dashboard JWT, not API key."}],"paths":{"/verifai/v1/registerDevice":{"post":{"tags":["Device verification"],"summary":"Register a device on first login","description":"Captures the device profile (12 hashed signal categories + optional Play Integrity verdict) and stores it as the canonical trusted-device record for this `(developer, user, device)`. Subsequent verify calls from the same device will return TRUSTED.","security":[{"ApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifAIVerifyRequest"}}}},"responses":{"200":{"description":"Device registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifAIRegisterResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/verifai/v1/verifyDevice":{"post":{"tags":["Device verification"],"summary":"Verify a device on subsequent logins","description":"Compares the incoming signal hashes against the stored profile and returns one of TRUSTED / NEW_DEVICE / REJECTED. See `reason` field for non-TRUSTED outcomes.","security":[{"ApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifAIVerifyRequest"}}}},"responses":{"200":{"description":"Verify completed (status field carries the verdict).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifAIVerifyResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/verifai/v1/registerToken":{"post":{"tags":["Device verification"],"summary":"Register an FCM token for cross-device approval push","description":"Stores the FCM token on the user's most-recent (or specified) device so future approval requests can push to it.","security":[{"ApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["userId","fcmToken"],"properties":{"userId":{"type":"string"},"fcmToken":{"type":"string","minLength":8,"maxLength":1000},"deviceId":{"type":"string","nullable":true,"description":"Optional — if omitted, the user's most recently seen device is updated"}}}}}},"responses":{"200":{"description":"Token saved.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/verifai/v1/listDevices":{"get":{"tags":["Device verification"],"summary":"List trusted devices for a user","security":[{"ApiKey":[]}],"parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Devices.","content":{"application/json":{"schema":{"type":"object","required":["devices"],"properties":{"devices":{"type":"array","items":{"$ref":"#/components/schemas/VerifAIDevice"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/verifai/v1/getTrustScore":{"get":{"tags":["Device verification"],"summary":"Get current trust level + score for a device","security":[{"ApiKey":[]}],"parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"string"}},{"name":"deviceId","in":"query","required":true,"schema":{"type":"string","minLength":8}},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["android","pc","ios","web"],"default":"android"}}],"responses":{"200":{"description":"Trust info.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifAITrustScore"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/verifai/v1/deleteDevice":{"delete":{"tags":["Device verification"],"summary":"Remove a trusted device","security":[{"ApiKey":[]}],"parameters":[{"name":"deviceId","in":"query","required":true,"schema":{"type":"string","minLength":8}},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["android","pc","ios","web"]}}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/verifai/v1/approveDevice":{"post":{"tags":["Cross-device approval"],"summary":"Approve a pending session from the primary device","description":"Called by the trusted device after the user taps \"approve\" in the cross-device approval push. Promotes the new device to trusted.","security":[{"ApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sessionId"],"properties":{"sessionId":{"type":"string"},"approvedBy":{"type":"string","nullable":true,"description":"Free-form audit metadata"}}}}}},"responses":{"200":{"description":"Approved.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/verifai/v1/rejectDevice":{"post":{"tags":["Cross-device approval"],"summary":"Reject a pending session","security":[{"ApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sessionId"],"properties":{"sessionId":{"type":"string"},"reason":{"type":"string","nullable":true,"description":"Free-form rejection reason for audit logging"}}}}}},"responses":{"200":{"description":"Rejected.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/verifai/v1/getSession":{"get":{"tags":["Cross-device approval"],"summary":"Fetch the current state of an approval session (3.3.0+)","description":"Used by the **new-device side** to wait for a trusted device to tap Approve / Deny. Called in a poll loop (default every 2s) by the web SDK's `pollSession()` and the Android SDK's `VerifAI.pollSession()`. The trusted device side gets an FCM push and uses `/approveDevice` / `/rejectDevice`.","security":[{"ApiKey":[]}],"parameters":[{"name":"sessionId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Session state.","content":{"application/json":{"schema":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["pending","approved","rejected","expired"]},"approvedBy":{"type":"string","nullable":true,"description":"Free-form label set by the approver. Only present on `status=\"approved\"`."},"rejectionReason":{"type":"string","nullable":true,"description":"Only present on `status=\"rejected\"`."},"expiresAt":{"type":"integer","description":"Unix epoch ms — when the pending session auto-expires."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/verifai/v1/listPendingSessions":{"get":{"tags":["Cross-device approval"],"summary":"List pending approval sessions for a user (3.3.0+)","description":"Used by the **trusted device** as a polling fallback when FCM push isn't available (notification permission denied, browser-private mode, etc.). The dashboard / host app polls every few seconds to discover new pending sign-in attempts and pops the approval UI without depending on push delivery.","security":[{"ApiKey":[]}],"parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Latest pending session for the user, or `{session: null}` when none.","content":{"application/json":{"schema":{"type":"object","properties":{"session":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"userId":{"type":"string"},"status":{"type":"string"},"deviceType":{"type":"string"},"requestingDeviceModel":{"type":"string"},"requestingIp":{"type":"string","nullable":true},"createdAt":{"type":"integer"},"expiresAt":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/verifai/v1/events":{"get":{"tags":["Developer console"],"summary":"Recent events for the customer console","description":"Used by the dashboard's \"Recent events\" feed. Combines verifai_sessions decisions and verifai_devices logins.","security":[{"Dashboard":[]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":7}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":100}}],"responses":{"200":{"description":"Event list.","content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["session","login"]},"id":{"type":"string"},"userId":{"type":"string"},"deviceType":{"type":"string"},"deviceIdShort":{"type":"string"},"decision":{"type":"string","description":"TRUSTED / approved / rejected / pending / different_network / etc."},"reason":{"type":"string","nullable":true},"ip":{"type":"string","nullable":true},"at":{"type":"string","format":"date-time"}}}}}}}}}}}},"/verifai/v1/stats":{"get":{"tags":["Developer console"],"summary":"Aggregated counts for the dashboard chart","security":[{"Dashboard":[]}],"parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":365,"default":30}}],"responses":{"200":{"description":"Totals + per-day series.","content":{"application/json":{"schema":{"type":"object","properties":{"windowDays":{"type":"integer"},"totals":{"type":"object","properties":{"patternCollections":{"type":"integer"},"verifications":{"type":"integer"},"approvals":{"type":"integer"},"rejections":{"type":"integer"},"expired":{"type":"integer"},"pending":{"type":"integer"},"blocks":{"type":"integer","description":"rejections + expired"}}},"series":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","format":"date-time"},"recordType":{"type":"string","enum":["verifai_pattern_collect","verifai_verify","verifai_block"]},"count":{"type":"integer"}}}}}}}}}}}},"/verifai/v1/devices":{"get":{"tags":["Developer console"],"summary":"Paginated device list across all your users (console view)","security":[{"Dashboard":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":50}},{"name":"userId","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Devices.","content":{"application/json":{"schema":{"type":"object","required":["devices"],"properties":{"devices":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/VerifAIDevice"},{"type":"object","properties":{"userId":{"type":"string"}}}]}}}}}}}}}},"/verifai/v1/config":{"get":{"tags":["Configuration"],"summary":"Read VerifAI feature + settings state for this developer's API key","description":"Returns the resolved state of every VerifAI feature toggle plus the per-key tunable settings on the developer's primary API key. Boolean feature flags that are admin-controlled appear read-only; numeric/group settings and the `sameNetworkOnly` toggle are self-service via PATCH below. Surface in your dashboard so customers can see what's on without calling support.","security":[{"Dashboard":[]}],"responses":{"200":{"description":"Config.","content":{"application/json":{"schema":{"type":"object","properties":{"hasKey":{"type":"boolean","description":"Developer has at least one provisioned API key. When false, all other flags are reported as defaults and settings are not yet attached."},"sameNetworkOnly":{"type":"boolean","description":"`verifai-same-network-only` — when ON, off-network logins auto-rejected. Writable via PATCH."},"patterns":{"type":"boolean","description":"`verifai-patterns` — device-fingerprint collection on register/verify."},"verification":{"type":"boolean","description":"`verifai-verification` — login-decision flow (TRUSTED / NEW_DEVICE / REJECTED + approval sessions)."},"behavioral":{"type":"boolean","description":"`verifai-behavioral` — behavioral biometrics collected on verify, trained over `trainingLogins` logins, then compared."},"behavioralBlock":{"type":"boolean","description":"`verifai-behavioral-block` — when ON, a behavioral mismatch overrides verify status to REJECTED. Requires `behavioral` ON to have any effect."},"strictBlock":{"type":"boolean","description":"3.1.0+: `verifai-strict-block` — when ON, any anomaly after training forces REJECTED with reason `strict_block:<anomalies>`."},"behavioralQuorum":{"type":"boolean","description":"3.4.0+: `verifai-behavioral-quorum` — swap total-mismatch tolerance for a per-family majority-match quorum (default ≥2 high-entropy families must majority-match). Reduces false-rejections on noisy days without weakening the asymmetric block against strangers, who fail entire families at once. Requires `behavioral` ON."},"behavioralRescue":{"type":"boolean","description":"3.4.0+: `verifai-behavioral-rescue` — when a verify arrives with an unknown `deviceIdHash` for a user who already has exactly one trusted+trained device of the same type and the behavioral signature matches, silently migrate the stored hash to the new value and return TRUSTED instead of opening a cross-device approval session. Closes the failure mode where a legitimate salt rotation (Android `pm clear`, app reinstall, Keystore re-key) bricks single-device accounts."},"groups":{"type":"object","description":"3.2.0+: Four pattern groups. Each group has an admin gate (`adminAllowed`, from the feature catalog) AND a dev opt-in (`devEnabled`, stored in `api_keys.verifai_settings.enabledGroups`). A category is compared only when both are true.","properties":{"touch":{"type":"object","properties":{"adminAllowed":{"type":"boolean"},"devEnabled":{"type":"boolean"}}},"motion":{"type":"object","properties":{"adminAllowed":{"type":"boolean"},"devEnabled":{"type":"boolean"}}},"keystroke":{"type":"object","properties":{"adminAllowed":{"type":"boolean"},"devEnabled":{"type":"boolean"}}},"network":{"type":"object","properties":{"adminAllowed":{"type":"boolean"},"devEnabled":{"type":"boolean"}}}}},"advancedPatterns":{"type":"object","description":"3.2.0+: Day-of-week + hour-bucket baseline. Two-level toggle: admin enables the capability (`adminAllowed`), dev opts in for their app (`devOptedIn`).","properties":{"adminAllowed":{"type":"boolean"},"devOptedIn":{"type":"boolean"}}},"contactLock":{"type":"object","description":"3.2.0+: Contact-overlap matcher (Android only). Admin gates the capability; the developer sets the match threshold (0-100, default 80).","properties":{"adminAllowed":{"type":"boolean"},"threshold":{"type":"integer","minimum":0,"maximum":100}}},"trainingLogins":{"type":"integer","minimum":3,"maximum":50,"description":"3.2.0+: How many successful logins before the behavioral baseline locks. Default 10. Higher = more lenient (impostor may land inside trained window). Lower = more aggressive."}}}}}}}},"patch":{"tags":["Configuration"],"summary":"Update self-service VerifAI settings on the developer's own key","description":"All fields are optional. Booleans for admin-controlled flags (patterns, verification, behavioral, behavioralBlock, strictBlock, advancedPatterns.adminAllowed, contactLock.adminAllowed) are rejected here — those go through the admin API. Returns the same shape as GET.","security":[{"Dashboard":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"sameNetworkOnly":{"type":"boolean","description":"Toggle the same-network gate."},"trainingLogins":{"type":"integer","minimum":3,"maximum":50,"description":"3.2.0+: How many successful logins before the behavioral baseline locks."},"contactLockThreshold":{"type":"integer","minimum":0,"maximum":100,"description":"3.2.0+: Match threshold (% overlap) below which `contacts.status` is reported as `mismatch`."},"advancedPatternsDevOptIn":{"type":"boolean","description":"3.2.0+: Opt in (or out) of advanced patterns. Requires admin to have enabled the capability — otherwise the field saves but advanced patterns won't run."},"enabledGroups":{"type":"object","description":"3.2.0+: Per-group dev opt-in. Sending a partial object only updates the keys included.","properties":{"touch":{"type":"boolean"},"motion":{"type":"boolean"},"keystroke":{"type":"boolean"},"network":{"type":"boolean"}}}}}}}},"responses":{"200":{"description":"Updated config (same shape as GET).","content":{"application/json":{"schema":{"$ref":"#/paths/~1verifai~1v1~1config/get/responses/200/content/application~1json/schema"}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}}}}