Developers

Start it from your app. Trust only your backend.

Integrating Sonavera is a few calls and a redirect. We handle consent, cameras, the guided session, and the evidence math. You validate the result server-side and apply your own rules.

Everything user-facing runs on our side. Everything decision-making runs on yours.

Where the boundary sits
1. Startyour app opens a handshake
2. Redirectthe user completes the check with us
Sonavera runsConsent, session, evidence
3. Validatetoken, API result, or webhook
4. Decideyour policy, your action
BrowserNo app install
ProtocolsOIDC or API redirect
DeliveryTokens, API retrieval, webhooks
PolicyStays in your application

Quick start

Three steps, one afternoon.

  1. 1POST /handshake
  2. 2Redirect the user to verificationUrl
  3. 3GET /handshake/{id}/result

Keep your API credentials in your backend. The browser’s return trip is a doorbell, not a result. Fetch the real thing server-side before acting on it.

Authenticated retrieval remains the source of truth. “Offline” result-token validation means backend-local validation after a trusted queue, job, workflow, verified webhook, or stored-result handoff, not validation in the browser.

Validate before the signed exp. Result tokens currently expire no later than five minutes after issue and can expire sooner with the handshake. The 60-second tolerance covers clock skew only. A token first received near handshake expiry may already be unsuitable for independent validation; use authenticated retrieval instead. A verified webhook may queue a token, but the consumer must validate it before exp. If it is expired or has too little lifetime, retrieve the authoritative result instead of weakening expiry validation.

During a pilot we’ll help you pick the right flow and sensible starting thresholds. The thresholds (and the decision) stay yours.

Minimal TypeScriptbackend only
import { randomUUID } from 'node:crypto'
import {
  getSonaveraApiRedirectResult,
  startSonaveraApiRedirectHandshake,
  validateSonaveraResultToken,
} from '@sonavera-ai/tenant-sdk'

const credentials = {
  baseUrl: process.env.SONAVERA_BASE_URL!,
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
}

const logicalActionId = '<your durable action ID>'
// appStore is your durable backend database adapter. This operation must
// atomically invoke the factory once, persist the caller-owned key before the
// first network request, and return the same record for every retry.
const start = await appStore.getOrCreateSonaveraStart(logicalActionId, () => ({
  idempotencyKey: randomUUID(),
  tenantSubjectId: '<stable pseudonymous user UUID>',
}))

const handshake = await startSonaveraApiRedirectHandshake({
  ...credentials,
  idempotencyKey: start.idempotencyKey,
  type: 'verify',
  tenantSubjectId: start.tenantSubjectId,
  expires: 180,
  returnUrl: 'https://app.example.com/sonavera/return',
})

await appStore.saveHandshakeId(logicalActionId, handshake.handshakeId)
return Response.redirect(handshake.verificationUrl, 302)

// In your backend return/polling handler:
// Resolve logicalActionId from authenticated, server-side transaction state.
const { handshakeId } = await appStore.loadSonaveraStart(logicalActionId)
const result = await getSonaveraApiRedirectResult({
  ...credentials,
  handshakeId,
})

if (result.terminal && result.result.ceremony.status === 'completed') {
  // Evaluate the evidence under your application policy.
}

// Only when a trusted backend stage independently relies on resultToken.
// Queued consumers validate before exp; on expiry, use authenticated retrieval.
const queued = await appStore.loadQueuedSonaveraResult(logicalActionId)
const validatedClaims = await validateSonaveraResultToken({
  resultToken: queued.resultToken,
  issuer: process.env.SONAVERA_OIDC_ISSUER!,
  expectedTenantId: queued.tenantId,
  expectedHandshakeId: queued.handshakeId,
  expectedFlow: 'verify',
  expectedTenantSubjectId: queued.tenantSubjectId,
})

Integration paths

Pick the path that works for you.

Every path delivers the same evidence result. What differs is how it reaches you and what your backend has to verify along the way. Check the session finished and the operation happened before you read the evidence.

01 / OIDC step-up

Speak the protocol your identity stack already knows

Authorization Code with S256 PKCE, ending in an ES256-signed ID token that carries the evidence. Start it from your backend or BFF, or from a SPA, as long as your backend does the validating.

  1. 1Starting from a backend or BFF: keep state, nonce, and code_verifier server-side, bound to the transaction you’re protecting.
  2. 2Starting from a SPA: the browser can hold state and code_verifier, but your backend must issue or verify the action-binding nonce and pre-bind the expected flow and subject before anything is trusted.
  3. 3Validate the returned ID token in your backend: ES256 signature against our JWKS, issuer, audience, expiry, nonce, ACR, and the Sonavera flow and subject claims - matched against your own transaction state.
  4. 4Only then apply your policy. A token you haven’t validated is just a string.
02 / API redirect

The simplest path: two calls and a redirect

Create the check with your API credentials, send the browser to us, fetch the result when the user comes back. No OIDC expertise required.

  1. 1Create the handshake from your backend and bind it to the action under review.
  2. 2Send the browser to the verification URL we return.
  3. 3When the user comes back, treat the query parameters as a doorbell, not a result. Fetch the real result server-side with your credentials.
  4. 4When a trusted backend job, queue, workflow, or stored-result reader independently relies on resultToken without another Sonavera API call, validate it at the trusted boundary with the backend-only SDK 0.11.0 helper and caller-owned transaction context. The signed expiry is no later than five minutes after issue and can be too short near handshake expiry; use authenticated retrieval instead in that case. Do not validate it in the browser.
03 / Signed webhooks

Hear about results instead of polling for them

Optional handshake.result.v1 deliveries carry the same result envelope, HMAC-signed so your backend can prove who sent them.

  1. 1Point us at an HTTPS endpoint and keep the webhook secret in your backend.
  2. 2Verify the signature over the exact raw body (before parsing) and check that the timestamp is fresh.
  3. 3After verification, parse the signed body and require the event headers to match it.
  4. 4Use the signed body event ID as a transactional idempotency key. Commit policy state and the completion marker together. For external effects, write an event-ID-keyed outbox record in the same transaction.
  5. 5Return 429 when overloaded or a 5xx response when processing fails temporarily so the delivery remains retryable. Other non-2xx responses are treated as terminal.
  6. 6Keep authenticated backend retrieval as your recovery path. Webhooks are a convenience, not the source of truth.

Sample result

Read a result before you write any code.

This is rendered from the same generated fixture our tests use: real structure, real fields, example numbers. It’s deliberately unsigned: useful for reading and rendering, not for proving anything.

Because the excerpt and the full document come straight from the generatedverify/partial fixture, they can’t drift from the contract. Confirm the current contract package during a pilot before you build for production.

Evidence exampleunsigned · verify/partial
{
  "scenario": "partial",
  "illustrative": true,
  "signed": false,
  "artifact": {
    "flow": "verify",
    "profile": {
      "id": "evidence_verification",
      "version": 3,
      "uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-profiles#evidence_verification/v3",
      "digest": "sha256:47eb4237550591d6665cb73de39b614da290aa0845c80e36896aa98dff24f7cd"
    },
    "index_score": 80,
    "index_measurements_status": "measurements_partial",
    "evidence_axes": [
      {
        "id": "identity_match",
        "measurement_normalized": 76.25,
        "index_points_earned": 30.5
      },
      {
        "id": "face_continuity",
        "measurement_normalized": 88,
        "index_points_earned": 8.8
      },
      {
        "id": "challenge_integrity",
        "measurement_normalized": 82,
        "index_points_earned": 24.6
      },
      {
        "id": "media_integrity",
        "measurement_normalized": 82,
        "index_points_earned": 16.4
      }
    ]
  }
}
Complete raw JSON
{
  "example_schema": 1,
  "scenario": "partial",
  "illustrative": true,
  "signed": false,
  "artifact": {
    "schema": 1,
    "provider": "Sonavera",
    "schema_uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-schema#v1",
    "flow": "verify",
    "profile": {
      "id": "evidence_verification",
      "version": 3,
      "uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-profiles#evidence_verification/v3",
      "digest": "sha256:47eb4237550591d6665cb73de39b614da290aa0845c80e36896aa98dff24f7cd"
    },
    "index_score": 80,
    "index_measurements_status": "measurements_partial",
    "evidence_axes": [
      {
        "id": "identity_match",
        "measurement_normalized": 76.25,
        "index_points_allocated": 40,
        "index_points_earned": 30.5,
        "checks": [
          {
            "id": "face.identity_match.v1",
            "contributes_to_index": true,
            "measurement_status": "measurement_partial",
            "measurement_reason": "samples_insufficient",
            "samples_target": 10,
            "samples_collected": 9,
            "measurement_normalized": 73.8,
            "index_points_allocated": 28,
            "index_points_earned": 20.66
          },
          {
            "id": "voice.identity_match.v1",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "samples_target": 1,
            "samples_collected": 1,
            "measurement_normalized": 82,
            "index_points_allocated": 12,
            "index_points_earned": 9.84
          }
        ]
      },
      {
        "id": "face_continuity",
        "measurement_normalized": 88,
        "index_points_allocated": 10,
        "index_points_earned": 8.8,
        "checks": [
          {
            "id": "face.session_continuity.v2",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "measurement_normalized": 88,
            "index_points_allocated": 10,
            "index_points_earned": 8.8,
            "details": {
              "algorithm_version": "anchorless_pairwise.v2",
              "observation_window_ms": 32000,
              "opportunities_scheduled": 32,
              "opportunities_evaluable": 31,
              "primary_face_observations": 30,
              "reliable_embeddings": 28,
              "temporal_bins_covered": 4,
              "longest_unobserved_gap_ms": 1500,
              "embedding_consistency_normalized": 88,
              "primary_face_observability_normalized": 80
            }
          }
        ]
      },
      {
        "id": "challenge_integrity",
        "measurement_normalized": 82,
        "index_points_allocated": 30,
        "index_points_earned": 24.6,
        "checks": [
          {
            "id": "phrase.challenge_response.v1",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "measurement_normalized": 82,
            "index_points_allocated": 10,
            "index_points_earned": 8.2
          },
          {
            "id": "liveness.active_challenge.v1",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "measurement_normalized": 82,
            "index_points_allocated": 20,
            "index_points_earned": 16.4
          }
        ]
      },
      {
        "id": "media_integrity",
        "measurement_normalized": 82,
        "index_points_allocated": 20,
        "index_points_earned": 16.4,
        "checks": [
          {
            "id": "pad.video_authenticity.v2",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "measurement_normalized": 82,
            "index_points_allocated": 14,
            "index_points_earned": 11.48
          },
          {
            "id": "pad.voice_authenticity.v1",
            "contributes_to_index": true,
            "measurement_status": "measurement_complete",
            "measurement_reason": null,
            "measurement_normalized": 82,
            "index_points_allocated": 6,
            "index_points_earned": 4.92
          }
        ]
      }
    ],
    "supplemental_checks": [
      {
        "id": "browser.authenticity.v1",
        "contributes_to_index": false,
        "measurement_status": "measurement_complete",
        "measurement_reason": null,
        "measurement_normalized": 75,
        "index_points_allocated": 0,
        "index_points_earned": 0
      },
      {
        "id": "network.authenticity.v1",
        "contributes_to_index": false,
        "measurement_status": "measurement_complete",
        "measurement_reason": null,
        "measurement_normalized": 70,
        "index_points_allocated": 0,
        "index_points_earned": 0
      },
      {
        "id": "browser.continuity.v1",
        "contributes_to_index": false,
        "measurement_status": "measurement_complete",
        "measurement_reason": null,
        "measurement_normalized": 100,
        "index_points_allocated": 0,
        "index_points_earned": 0
      },
      {
        "id": "network.continuity.v1",
        "contributes_to_index": false,
        "measurement_status": "measurement_complete",
        "measurement_reason": null,
        "measurement_normalized": 100,
        "index_points_allocated": 0,
        "index_points_earned": 0
      }
    ]
  }
}

TypeScript SDK

One package, pinned on purpose.

The SDK validates the integration pieces it owns: result shape, profile digest, OIDC claims, backend-only signed API Redirect result tokens, and webhook signatures. Your integration fails loudly instead of quietly. It contains none of your policy. While we’re pre-1.0, install the exact version shown and upgrade deliberately.

Available on npm@sonavera-ai/tenant-sdk@0.11.0
npm install --save-exact @sonavera-ai/tenant-sdk@0.11.0

Browser and device support

Media setup is built into the check.

A Sonavera check uses WebRTC and requires access to a camera and microphone. Before guided capture begins, the hosted preflight screen checks each device, shows a live preview, and guides the user through permissions, device selection, retry, or recovery.

Browser coverage we verify before each release
PlatformBrowserCovered
Windows and macOSCurrent ChromeTerms, consent, media setup, completion, evidence, deletion
iPhoneCurrent SafariFull flow plus camera and microphone permission recovery
AndroidCurrent ChromeFull flow plus camera and microphone permission recovery
  • Camera and microphone status are handled independently, with specific guidance for each device.
  • Permission recovery and device selection happen within the hosted flow.
  • Live captions, labeled controls, visible focus states, and reduced-motion support are built into the participant experience.
  • Completing the guided capture requires usable camera and microphone input. Sonavera manages the readiness and recovery experience around that requirement.

A few calls and a redirect.

Read the sample result, then talk to us when you’re ready to protect something real.