npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@sonavera-ai/tenant-sdk

v0.12.0

Published

TypeScript helpers for Sonavera tenant OIDC, API redirect, webhook, and deletion integrations.

Downloads

295

Readme

@sonavera-ai/tenant-sdk

TypeScript SDK for Sonavera tenant integrations.

License: MIT.

This package gives tenants the core protocol helpers needed for Sonavera's supported integration paths:

  • OIDC RP flow: authorize URL construction, PKCE helpers, token exchange, and ID token validation
  • public-client OIDC flow: browser-safe transaction start helpers for Authorization Code + PKCE
  • API redirect flow: handshake start, authoritative result retrieval, and backend-only signed result-token validation
  • tenant-key uniqueness scope creation for backend-owned uniqueness windows
  • tenant-mediated, flow-complete subject deletion with stable terminal results
  • shared webhook typing and signature verification for asynchronous handshake outcome delivery

The package is backend-first. It owns protocol helpers and validation, not the verification ceremony itself. Sonavera owns the browser verification UI and WebRTC ceremony runtime through the hosted edge client/product experience. Tenant applications use this SDK to initiate or validate integrations around that ceremony.

The package does not include:

  • tenant-facing verification UI
  • WebRTC or LiveKit ceremony runtime code
  • tenant application session, cookie, or database persistence
  • tenant-specific allow/block policy decisions
  • framework-specific routing adapters such as Next.js or Express middleware

The OIDC helper subset is browser-safe for public-client Authorization Code + PKCE flows. API redirect, result-token validation, and webhook helpers remain backend-only, and Sonavera token validation should still happen on the tenant backend before a protected action is allowed.

Runtime requirements

  • ESM-capable Node runtime for backend usage, or a modern browser for the public-client OIDC helper subset
  • global fetch
  • Web Crypto (crypto.subtle, crypto.getRandomValues)

Node 20+ is the simplest local target. If your runtime does not provide fetch, pass fetchImpl explicitly.

SDK helpers that call Sonavera upstream endpoints apply a 10 second fetch timeout by default. API Redirect handshake start uses a 30 second default because Sonavera may hold the create request behind a short service-instance overload queue. Pass fetchTimeoutMs to the individual helper call to use a shorter or longer positive timeout for that operation.

Install

npm install --save-exact @sonavera-ai/[email protected]

The package ships built ESM JavaScript and declaration files, so it can be consumed from standard NodeNext TypeScript projects without bundler-only module resolution.

This source prepares Evidence Index profile v4 for SDK 0.12.0, including pad.voice_authenticity.v2 for operator-selected audio providers. Its strict parser accepts current v4 artifacts and published v3 artifacts under their original check definitions and digests. Release automation publishes the package before profile-v4 services roll; tenant integrations must install this reader before receiving v4 artifacts. Proof-bound API Redirect behavior is unchanged.

Sonavera endpoints and tenant setup

For production tenant integrations, use:

  • API redirect baseUrl: https://api.sonavera.ai
  • OIDC issuer: https://api.sonavera.ai/op

The package does not infer a staging URL. If Sonavera provisions a staging or pilot-specific environment for a tenant, use the base URL and OIDC issuer for that environment.

API redirect responses return a verificationUrl under the hosted Sonavera ceremony surface. Tenants should redirect the browser to the returned URL rather than constructing ceremony URLs themselves. The URL includes a short-lived one-time entry proof. Treat the complete URL as an opaque browser credential: do not parse it, strip its query, log it, send it through analytics, or reuse it for a different browser. SDK 0.12.0 retains strict rejection of obsolete bare-UUID response URLs. Edge remains the authoritative enforcement boundary.

Local full-stack development and the demo RP use:

  • local API redirect baseUrl: http://localhost:8081
  • local OIDC issuer: http://localhost:8081/op
  • local demo return URL: http://localhost:8787/
  • local demo webhook receiver: http://localhost:8787/webhooks/sonavera/handshake-result

Before a live tenant integration can work, Sonavera and the tenant need to coordinate:

  • a Sonavera tenant record
  • backend-only tenant API key and API secret credentials for API redirect and tenant-mediated deletion
  • exact API redirect return URLs used by the tenant application
  • optional tenant webhook endpoint URL and shared webhook secret, configured by a tenant admin in the tenant account webhook settings or by a Sonavera operator through the admin webhook endpoint
  • OIDC client registration, redirect URIs, and browser origins if using OIDC

API Redirect handshake creation accepts an absolute returnUrl in each backend request and rejects values that are not registered for the tenant. The SDK sends the returnUrl on handshake creation; it does not register allowlist entries. Tenant admins can manage registrations in the tenant panel at /tenant/api-redirect-return-urls or through the tenant REST API mounted at /tenant/api-redirect-return-url. Sonavera operators can use the admin panel at /api-redirect-return-urls or the admin REST API mounted at /admin/api-redirect-return-url. OIDC redirect URIs are registered separately on the OIDC client and are enforced during authorization.

The full normalized API Redirect return URL must match exactly. Query parameters are part of the registered value, fragments are rejected, non-loopback hosts must use https, and loopback development URLs may use http only with an explicit port.

Tenant reference IDs

Every tenant-provided ceremony reference is a canonical RFC 9562 UUIDv4. Generate it with a cryptographically secure platform facility such as crypto.randomUUID() or the SDK's createSonaveraTenantReferenceId(), persist it in lowercase canonical form, and reuse it when the same tenant-side subject or interaction is intended. Existing UUIDv4 identifiers are valid. Do not derive these values from names, email addresses, phone numbers, usernames, account numbers, counters, Math.random(), or deterministic hashes of personal data. UUIDv4 references are pseudonymous personal data, not anonymous data.

Every flow requires tenantSubjectId as its tenant-scoped deletion association. Enroll/verify also use it as the external-user identity. Uniqueness additionally uses scopeSubjectId with scopeId, and liveness additionally uses interactionId. The interaction remains the distinct liveness action boundary, while tenantSubjectId may key only same-liveness consent reuse in the same tenant. Uniqueness matching and consent reuse remain bound to scopeSubjectId and scopeId. For liveness and uniqueness, tenantSubjectId is deletion-only and does not appear in signed outputs. These roles are not separate namespaces: the same valid value may be reused across roles, flows, later ceremonies, or another tenant when that matches the tenant's model. Sonavera validates format, version, and variant; it cannot prove how a syntactically valid UUID was generated.

API redirect quick start

Start a handshake on the backend:

import { randomUUID } from 'node:crypto';
import { startSonaveraApiRedirectHandshake } from '@sonavera-ai/tenant-sdk';

const idempotencyKey = randomUUID();
const tenantSubjectId = randomUUID(); // Persist this tenant-side subject ID.
// Persist this key with the logical start before calling Sonavera.
const handshake = await startSonaveraApiRedirectHandshake({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  idempotencyKey,
  type: 'enroll',
  tenantSubjectId,
  expires: 180,
  returnUrl: 'https://tenant.example.com/sonavera/return',
});

// Redirect the browser to handshake.verificationUrl

If Sonavera explicitly returns 503 SERVICE_UNAVAILABLE with reason service_overloaded, the SDK retries handshake start once with bounded backoff and then surfaces the upstream error if overload persists. Generic 503 responses and network timeouts are not retried by this helper.

If the call has an ambiguous outcome, repeat the exact request with the same caller-owned idempotencyKey. A bound replay returns the same unexpired handshake with a refreshed proof-bound URL and sets handshake.replayed === true; a new creation sets it to false. The key is retained for 24 hours from the first reservation and replay does not extend that window. idempotency_request_in_progress is retryable with the identical request and key after the reported delay. A replay after the bound handshake expires throws SonaveraIntegrationError with code === 'idempotency_handshake_expired'; that reason and idempotency_key_mismatch are not retryable. Rotate the key only for a deliberate new logical start. The SDK deliberately does not generate keys or automatically retry generic timeouts because the caller must retain identity across failures.

Only one active semantic ceremony is admitted per tenant: enroll/verify share the external tenantSubjectId, liveness uses interactionId, and uniqueness uses scopeId plus scopeSubjectId. An overlap throws SonaveraIntegrationError with code === 'active_ceremony_conflict', status === 409, and details.reason === 'active_ceremony_conflict'. The SDK does not retry this error. Wait for the existing ceremony to become terminal or expire. API Redirect idempotency is transport identity only; a different key still reaches this semantic active-ceremony gate.

For uniqueness checks, create a first-class scope from the tenant backend, then pass its returned id with a canonical UUIDv4 scopeSubjectId:

import {
  createSonaveraUniquenessScope,
  startSonaveraApiRedirectHandshake,
} from '@sonavera-ai/tenant-sdk';

const uniquenessScope = await createSonaveraUniquenessScope({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  displayName: 'Launch reward — July 2026',
  // Leave headroom below the server's 24-hour maximum for clock skew and transit time.
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000 - 5 * 60 * 1000),
  metadata: { campaign: 'launch-reward-2026-07' },
});

const uniquenessHandshake = await startSonaveraApiRedirectHandshake({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  idempotencyKey: randomUUID(),
  type: 'uniqueness',
  tenantSubjectId,
  scopeSubjectId: randomUUID(),
  scopeId: uniquenessScope.id,
  expires: 180,
  returnUrl: 'https://tenant.example.com/uniqueness/return',
});

// uniquenessHandshake.scopeId identifies the bound uniqueness scope.

createSonaveraUniquenessScope calls the create-only tenant-key endpoint POST /uniqueness-scope. Tenant ownership comes from the API credentials; the request cannot select a tenant or scopeKey. List, read, update, and expire remain authenticated tenant/admin management operations. Scopes last at most 24 hours: their start is immutable, their expiry may only be shortened, and expiry/termination immediately removes all scoped observations from future comparison. Keep scope lifecycle and any rolling-window coordination on the tenant backend, never in the browser.

Uniqueness evidence keeps browser.continuity.v1 and network.continuity.v1 separate numeric supplementals. Handle their bounded 0..100 measurement together with status, reason, and safe details. No comparable prior observation returns measurement_absent with comparison_unavailable and a null measurement.

Every flow may also include browser.authenticity.v1 and network.authenticity.v1. Keep these checks separate from each other and from continuity. They are numeric, non-contributing heuristics—not probabilities of authenticity, proof of human participation, or human/bot verdicts. Respect complete, partial, and absent statuses instead of defaulting missing evidence to a favorable measurement.

Verification continuity binds browser and network to one latest successful tenant-scoped enrollment ceremony. An expired/deleted replacement or a source missing from that ceremony yields unavailable continuity rather than falling back to an older enrollment.

When a tenant collects its own pre-ceremony eligibility attestations, pass them as bounded metadata. These assertions record tenant-side collection context; they are not Sonavera identity, liveness, or allow/block decisions.

await startSonaveraApiRedirectHandshake({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  idempotencyKey: randomUUID(),
  type: 'liveness',
  tenantSubjectId,
  interactionId: randomUUID(),
  expires: 180,
  returnUrl: 'https://tenant.example.com/liveness/return',
  tenantAssertions: {
    schema: 'sonavera_tenant_assertions_v1',
    eligibility: {
      age_gate: '18_or_older',
      region_gate: 'supported_region',
      not_unsupported_region: true,
      participant_controls_face_voice_capture: true,
    },
    source: {
      collector: 'tenant',
      method: 'pre_ceremony_ui',
      collected_at: new Date().toISOString(),
    },
  },
});

After the browser returns, retrieve the authoritative result:

import { getSonaveraApiRedirectResult } from '@sonavera-ai/tenant-sdk';

const result = await getSonaveraApiRedirectResult({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  handshakeId,
});

if (result.terminal && result.result.ceremony.status === 'completed') {
  const evidence = result.result.claims?.['https://sonavera.ai/evidence'];
  // Apply tenant policy to the returned evidence package.
}

The result.result.ceremony object describes whether the handshake ceremony completed. It is not an identity, liveness, uniqueness, or tenant-action decision.

Validate a signed result token on the backend

SDK 0.8 adds validateSonaveraResultToken to the existing package root. The helper is backend-only. It verifies ES256 through issuer discovery/JWKS, pins sv-result+jwt, the configured/discovered/signed issuer, the exact scalar tenant audience, fixed 60-second clock tolerance, result-token use, ceremony and evidence shape, and flow-specific transaction bindings. It returns the validated flow-discriminated claims; it does not return an approve, block, review, or retry decision.

Use it when your integration independently relies on resultToken after that signed token crosses a trusted backend boundary—for example, after a signature-verified webhook is placed on a queue, in a durable job/workflow, or when a stored signed result is processed without another Sonavera API call. That is the supported offline validation scenario. It does not mean validating in a browser.

Offline does not mean indefinitely valid. Validate before the signed exp: the current result-token issuer caps expiry at five minutes after iat and at the handshake expiry, whichever comes first (with only a one-second lifetime when an already-expired result is sealed). The fixed 60-second tolerance is for clock skew, not deferred processing. A token first received near or after the handshake expiry may have too little lifetime for independent validation; in that case, use authenticated result retrieval. A verified webhook may enqueue a token for an offline consumer, but that consumer must validate it before exp. After successful validation, persist the derived application state rather than re-validating the raw token later. If the consumer receives an expired token or one with too little lifetime, retrieve the authoritative result instead of weakening expiry validation.

Authenticated getSonaveraApiRedirectResult retrieval remains the source of truth. Code acting directly on that authenticated envelope does not need an extra token-validation round trip. Never decode an unverified token to obtain the expected tenant, handshake, flow, or reference values.

import { validateSonaveraResultToken } from '@sonavera-ai/tenant-sdk';

// Load these values from your own authenticated, durable transaction record.
const trustedJob = await appStore.loadSonaveraResultJob(jobId);

const claims = await validateSonaveraResultToken({
  resultToken: trustedJob.resultToken,
  issuer: 'https://api.sonavera.ai/op',
  expectedTenantId: trustedJob.tenantId,
  expectedHandshakeId: trustedJob.handshakeId,
  expectedFlow: 'verify',
  expectedTenantSubjectId: trustedJob.tenantSubjectId,
});

// Apply your policy only after validation succeeds.
const evidence = claims['https://sonavera.ai/evidence'];

The flow discriminator requires exactly these trusted expectations:

| expectedFlow | Required caller-owned context | | --- | --- | | enroll / verify | expectedTenantSubjectId | | liveness | expectedInteractionId | | uniqueness | expectedScopeSubjectId and expectedScopeKey |

expectedTenantId is normalized to a lowercase UUID and the SDK constructs the one allowed audience, urn:sonavera:tenant:<tenantId>. There is no public expected-audience option. Mismatched flow-specific input combinations are rejected, and unrelated future JWT claims remain inert until the SDK explicitly types and consumes them.

Sensor-data sufficiency and deliverable outcome

result.result also carries two fields describing collection completeness — whether Sonavera captured the required critical-sensor data — which is distinct from the evidence strength in the evidence claim. Weak evidence (e.g. a low face-similarity verify) is never a failure and never insufficient data; it is a performed operation for you to interpret.

The public result has no categorical assessment field. Use ceremony lifecycle, sensor-data sufficiency, deliverable outcome, and the signed evidence claim as separate inputs to tenant-owned policy.

  • sensorData: { sufficient, reason, sensors[] }, or null when collection was never evaluated (the ceremony ended before capture — e.g. consent declined or expired untouched — or failed on a transport/internal error). null is never a statement about the data; only a present object carries a sufficiency reading. When sufficient is false, reason is one of no_video_frames, no_face_detected, insufficient_face_frames, and sensors[] gives per-critical-sensor detail (sensor, framesCollected, sufficient, reason). The field is required; an omitted or malformed block is rejected as bad_upstream_payload. The SDK never fabricates a sufficiency value.
  • deliverableOutcome: no_enrollment | no_verification | no_liveness_result | no_uniqueness_result | null. A non-null value means the recorded outcome axes show that the requested deliverable was not produced (insufficient critical data, no enrolled reference for a verify, or an enrollment held back by the ceremony integrity gate). This most commonly accompanies a completed ceremony, but a not-completed terminal result can also carry it when those axes were evaluated before the ceremony ended. It describes whether the operation happened — never a verdict about the person. The field is required and accepts only the documented values; unknown values are rejected as bad_upstream_payload.
const { sensorData, deliverableOutcome } = result.result;
if (deliverableOutcome === 'no_enrollment') {
  // The ceremony completed, but no enrollment was established (e.g. the camera
  // was never enabled: sensorData.reason === 'no_video_frames'). Decide whether
  // to re-invite the participant — Sonavera never prompts them to retry.
}

If you validate only a Sonavera-signed JWT — the signed result token, or the id_token issued by the OIDC/PKCE flow — do not treat ceremony.status = completed alone as "verified" or "enrolled". Both tokens carry Sonavera outcome claims so you can tell a performed operation from an operation that did not occur without a second fetch:

  • https://sonavera.ai/operation_performed (boolean; enroll/verify only). A non-completed terminal ceremony carries false.
  • https://sonavera.ai/deliverable_outcome (no_enrollment | no_verification | no_liveness_result | no_uniqueness_result) — a completed enroll/verify with operation_performed=false carries its exact matching outcome. A not-completed enroll/verify may omit it when sufficiency was never evaluated; when present, it must still match the flow.
import type {
  SonaveraIdTokenPayload,
  SonaveraResultTokenPayload,
} from '@sonavera-ai/tenant-sdk';

export function requestedDeliverableWasProduced(
  claims: SonaveraIdTokenPayload | SonaveraResultTokenPayload,
): boolean {
  return (
    claims['https://sonavera.ai/ceremony'].status === 'completed'
    && claims['https://sonavera.ai/operation_performed'] !== false
    && claims['https://sonavera.ai/deliverable_outcome'] === undefined
  );
}

// The returned boolean describes the signed protocol outcome. Your application
// still owns the policy decision and should evaluate the evidence separately.

A low face-similarity verify is still a performed verification (no deliverable_outcome); read the evidence claim to decide what a weak match means for your policy.

After a terminal API redirect ceremony, the browser returns to returnUrl with advisory query parameters:

  • handshake_id: the Sonavera handshake UUID
  • terminal_status: one of COMPLETED, FAILED, CONSENT_DECLINED, USER_ENDED_SESSION, or EXPIRED
  • flow: enroll, verify, uniqueness, or liveness when known

Use these fields only to restore tenant UX state. The backend result lookup is authoritative for product decisions.

For evidence detail, inspect result.result.claims?.['https://sonavera.ai/evidence'] and validate it with parseSonaveraEvidenceArtifact. Schema 1 has one exact shape:

  • flow selects one generated profile: enrollment, verification, uniqueness, or liveness.
  • profile contains the profile ID, positive integer version, issuer URI, and pinned SHA-256 digest.
  • index_score is an integer from 0 through 100. It is compensatory evidentiary support, not a probability or Sonavera action recommendation. Display it without a percent sign.
  • index_measurements_status is measurements_complete, measurements_partial, or measurements_incomplete.
  • evidence_axes contains ordered axes with normalized measurements and exact allocated/earned points.
  • supplemental_checks contains numeric or qualitative checks with zero allocated and earned points. Their generated normalizer determines whether a complete or partial measurement is a number or null; absent is always null.
  • Every check uses measurement_complete, measurement_partial, or measurement_absent; count-based checks also carry the profile target and collected count.
  • Optional details values are limited to the published tenant-safe reason, match-key, coarse context allowlists, and strictly typed aggregate face continuity fields.

The SDK parser validates exact keys, flow/profile agreement, profile digest, fixed-point contribution arithmetic, sample targets, measurement state, and safe details. Unknown fields or a malformed completed artifact are rejected; the SDK does not guess, recompose, or accept an alternate shape.

Face continuity

Every current flow profile includes face.session_continuity.v2 as the only check in a 10-point face_continuity axis. It measures whether reliable embeddings of the geometrically selected primary face remained mutually consistent across sampled moments in the ceremony. It does not independently establish identity, liveness, uniqueness, media authenticity, uninterrupted presence, face/voice binding, fraud, or a participant count. It is sampled aggregate evidence, not every-frame tracking.

For complete evidence, the normalized value exactly equals embedding consistency. Face absence, ambiguity, and soft-only observations do not directly reduce that value. Aggregate observability remains a diagnostic field; insufficient evaluable coverage, reliable embeddings, temporal bins, or gap coverage still makes the result partial or absent under the existing gates. Those predicates form one binary sufficiency decision. Observation-level evaluation, provider, or capture missingness appears as a failure reason only when it makes the 80% evaluable-coverage predicate fail; otherwise aggregate scheduled/evaluable counts retain the diagnostic signal. A terminal whole-assessment provider or evaluation failure produces absent evidence regardless of already-collected opportunity counts. Complete evidence may carry only the non-gating primary-face-ambiguity detail reason. Partial continuity is always numeric 0 and earns zero points; absent continuity is null and earns zero. Embeddings, crops, boxes, individual timestamps, distances, and unrecognized detail keys are rejected because they are not artifact fields.

Display the normalized number without a percent sign, product score band, or pass/fail/category label. Apply tenant policy only after validating the whole signed artifact and considering its other evidence axes.

For renderer development, fetch an explicitly unsigned canonical envelope from /.well-known/sonavera-evidence-examples/<flow>/<scenario> and validate it with parseSonaveraEvidenceExample. Available scenarios are complete, partial, incomplete, measured-zero, supplemental-absent, and long-safe-details. These envelopes are illustrative test data, not ceremony proofs; never add them to a result cache or use them for an authorization decision.

A shortened verification excerpt looks like:

{
  "schema": 1,
  "provider": "Sonavera",
  "schema_uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-schema#v1",
  "flow": "verify",
  "profile": {
    "id": "evidence_verification",
    "version": 4,
    "uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-profiles#evidence_verification/v4",
    "digest": "sha256:4c0e10fd93293133880fc7386f3a2ecdf522c2a47b6a2a9a581224f7cf91aa4e"
  },
  "index_score": 79,
  "index_measurements_status": "measurements_partial",
  "evidence_axes": [
    {
      "id": "identity_match",
      "measurement_normalized": 28,
      "index_points_allocated": 40,
      "index_points_earned": 11.2,
      "checks": [
        {
          "id": "face.identity_match.v1",
          "contributes_to_index": true,
          "measurement_status": "measurement_partial",
          "measurement_reason": "samples_insufficient",
          "samples_target": 10,
          "samples_collected": 5,
          "measurement_normalized": 40,
          "index_points_allocated": 28,
          "index_points_earned": 11.2
        }
      ]
    }
  ],
  "supplemental_checks": []
}

The excerpt omits other profile-required axes and checks for readability. Use the generated SDK registry and parser as the machine contract; complete golden artifacts for all four flows live in services/orchestrator/tests/fixtures/evidence-index-v1.json.

Network details may contain only bounded geo_country, asn, ip_network_class, and tls_client_class context. Browser details may contain bounded automation_signals. Raw fingerprints, signal capsules, addresses, biometric material, internal identifiers, and candidate records are not artifact fields.

Tenants should apply their own risk rule after validation instead of treating Sonavera as an allow/block authority.

External-user deletion quick start

Use external-user deletion from a tenant backend after the tenant has authenticated or identified the person in its own system. One request covers all enroll, verify, liveness, and uniqueness handshakes associated with the tenant deletion subject across every scope, invalidates all historical consent, purges the selected active-system data, and deletes the external-user identity record when one exists.

import { randomUUID } from 'node:crypto';
import { deleteSonaveraExternalUser } from '@sonavera-ai/tenant-sdk';

const requestId = randomUUID();
// Persist requestId before the first call and retain it until terminal.
const deletion = await deleteSonaveraExternalUser({
  baseUrl: process.env.SONAVERA_BASE_URL || 'https://api.sonavera.ai',
  tenantApiKey: process.env.SONAVERA_TENANT_API_KEY!,
  tenantApiSecret: process.env.SONAVERA_TENANT_API_SECRET!,
  tenantSubjectId: '019141c4-9ad4-4f37-8f09-364744c459d7',
  requestId,
});

if (deletion.status === 'deleted') {
  // Selected Sonavera-held active-system records were deleted.
}

if (deletion.status === 'not_found') {
  // No covered platform records remained for that tenant subject.
}

Retry ambiguous, failed, or temporarily unavailable calls with the same tenant, subject, and request ID. A completed retry returns the recorded terminal result. Reusing the UUID for another subject is rejected. The response also reports deletedHandshakeCount and invalidatedConsentCount; not_found uses the same shape and reveals no cross-tenant existence.

SonaveraIntegrationError.code is request_in_progress for the retryable conflict and request_id_subject_mismatch when the UUID was already bound to another subject. The structured details retain retryAfterSeconds when present.

The tenant API key does not support deleting individual handshakes or individual artifacts. This operation does not cancel active ceremonies or provider sessions, fence concurrent creation, erase backups immediately, or promise deletion from independently retained provider systems or operational logs.

Webhook quick start

If the tenant configures a webhook endpoint, Sonavera can send handshake.result.v1 callbacks for terminal outcomes across either integration path. The callback data payload matches getSonaveraApiRedirectResult() exactly.

import {
  parseSonaveraHandshakeWebhookEvent,
  verifySonaveraWebhookSignature,
} from '@sonavera-ai/tenant-sdk';

const rawBody = await request.text();
const eventIdHeader = request.headers.get('x-sonavera-event-id');
const eventType = request.headers.get('x-sonavera-event');
if (!eventType || !eventIdHeader) {
  throw new Error('Missing Sonavera webhook event metadata');
}
const verified = await verifySonaveraWebhookSignature({
  secret: process.env.SONAVERA_WEBHOOK_SECRET!,
  timestamp: request.headers.get('x-sonavera-timestamp') || '',
  signature: request.headers.get('x-sonavera-signature') || '',
  rawBody,
});

if (!verified) {
  throw new Error('Invalid Sonavera webhook signature');
}

const event = parseSonaveraHandshakeWebhookEvent(JSON.parse(rawBody), eventType);
if (event.eventId !== eventIdHeader) {
  throw new Error('Webhook event ID header does not match the signed body');
}

// Insert or lock one row keyed by the signed event ID. Keep policy state and
// the completion marker in this transaction so a failure rolls everything back.
await database.transaction(async (tx) => {
  const state = await lockWebhookEvent(tx, event.eventId);
  if (state === 'completed') return;

  await applyTenantPolicy(tx, event.eventId, event.data);
  await markWebhookEventComplete(tx, event.eventId);
});

Sonavera sends webhook requests with these headers:

  • x-sonavera-event: currently handshake.result.v1
  • x-sonavera-event-id: the webhook event UUID; require it to match the signed body eventId, then use the body value as the transactional idempotency key
  • x-sonavera-timestamp: Unix timestamp in seconds
  • x-sonavera-signature: v1=<hex hmac sha256>
  • x-sonavera-retry-until: optional ISO 8601 timestamp of the last remaining retry opportunity that precedes the handshake artifact-deletion deadline

Tenant API calls require both the API key and API secret returned when the key is created. Integrations that only retained SONAVERA_TENANT_API_KEY must rotate or regenerate the tenant API key and store the new one-time SONAVERA_TENANT_API_SECRET alongside it.

verifySonaveraWebhookSignature() rejects timestamps outside a 5-minute tolerance by default. After verification, parse the signed body, require the event headers to match it, and commit customer-owned policy state and the body eventId completion marker in the same transaction. For effects outside that database, write an eventId-keyed outbox entry in the transaction and make its consumer idempotent. Already-completed events may be acknowledged. Return 429 when overloaded or a 5xx response when processing fails temporarily so the delivery remains retryable; other non-2xx responses are treated as terminal. Do not use the unsigned event-ID header alone as a replay key.

The signature input is the timestamp, a literal period, and the exact raw body:

v1=hex(hmac_sha256(secret, timestamp + "." + rawBody))

x-sonavera-retry-until is advisory delivery metadata and is not part of that signature input. Receivers may ignore it and must not use it for authorization or event integrity. Capacity-aware receivers can use it only to distinguish a temporary 5xx response from a terminal conflict when preserving replay state.

Use this deterministic fixture to test a receiver. The signature is valid only for this exact minified rawBody, timestamp, and secret. Because the verifier enforces timestamp freshness by default, pass nowEpochSeconds: 1774807200 in unit tests for this fixed fixture:

const secret = 'demo-secret';
const timestamp = '1774807200';
const signature =
  'v1=d088d6d6dcff9d31bd7260906d9a06ce4e9e7f6671c947f5205484c8f31f31da';
const rawBody =
  '{"eventId":"wh_123","eventType":"handshake.result.v1","occurredAt":"2026-03-29T18:00:00.000Z","data":{"handshakeId":"550e8400-e29b-41d4-a716-446655440000","type":"verify","tenantId":"550e8400-e29b-41d4-a716-446655440099","tenantSubjectId":"019141c4-9ad4-4f37-8f09-364744c459d7","status":"FAILED","terminal":true,"expiresAt":"2026-03-29T18:05:00.000Z","completedAt":"2026-03-29T18:00:00.000Z","result":{"ceremony":{"status":"not_completed","reason":"internal_error"},"claims":null,"resultToken":null,"failure":{"code":"internal_error","reason":"Ceremony failed."},"sensorData":null,"deliverableOutcome":"no_verification"}}}';

Production webhook endpoint URLs must be public HTTPS URLs with hostnames that resolve to public IP addresses. The local loopback webhook receiver is allowed only for the configured non-production dev bootstrap URL. For local pilot testing outside the repo's dev bootstrap, use a public HTTPS tunnel URL and configure that URL as the tenant handshake webhook endpoint.

OIDC quick start

Build an authorize URL:

import {
  buildSonaveraAuthorizeUrl,
  codeChallengeS256,
  generateCodeVerifier,
  generateOpaque,
} from '@sonavera-ai/tenant-sdk';

const codeVerifier = generateCodeVerifier();
const codeChallenge = await codeChallengeS256(codeVerifier);
const state = generateOpaque();
const nonce = generateOpaque();

const authorizeUrl = await buildSonaveraAuthorizeUrl({
  issuer: process.env.SONAVERA_OIDC_ISSUER || 'https://api.sonavera.ai/op',
  clientId: process.env.OIDC_CLIENT_ID!,
  redirectUri: 'https://tenant.example.com/callback',
  type: 'verify',
  tenantSubjectId: '019141c4-9ad4-4f37-8f09-364744c459d7',
  state,
  nonce,
  codeChallenge,
});

OIDC flow-specific inputs:

  • type: 'enroll' | 'verify': pass tenantSubjectId; the helper sends it as both the required deletion association and standard OIDC login_hint.
  • type: 'liveness': pass tenantSubjectId plus interactionId; the subject remains deletion-only and may key only same-liveness consent reuse in the same tenant; the interaction remains the distinct action boundary.
  • type: 'uniqueness': pass tenantSubjectId, scopeSubjectId, and first-class scopeId; the subject is deletion-only and does not change the scope population.
const uniquenessAuthorizeUrl = await buildSonaveraAuthorizeUrl({
  issuer: process.env.SONAVERA_OIDC_ISSUER || 'https://api.sonavera.ai/op',
  clientId: process.env.OIDC_CLIENT_ID!,
  redirectUri: 'https://tenant.example.com/uniqueness/callback',
  type: 'uniqueness',
  tenantSubjectId: '019141c4-9ad4-4f37-8f09-364744c459d7',
  scopeSubjectId: '6ba7b810-9dad-41d1-80b4-00c04fd430c8',
  scopeId: '550e8400-e29b-41d4-a716-446655440001',
  state,
  nonce,
  codeChallenge,
});

Exchange the returned authorization code:

import { exchangeSonaveraAuthorizationCode } from '@sonavera-ai/tenant-sdk';

const tokens = await exchangeSonaveraAuthorizationCode({
  issuer: process.env.SONAVERA_OIDC_ISSUER || 'https://api.sonavera.ai/op',
  clientId: process.env.OIDC_CLIENT_ID!,
  redirectUri: 'https://tenant.example.com/callback',
  code,
  codeVerifier,
});

Validate the Sonavera ID token:

import { validateSonaveraIdToken } from '@sonavera-ai/tenant-sdk';

const payload = await validateSonaveraIdToken({
  idToken: tokens.id_token,
  issuer: process.env.SONAVERA_OIDC_ISSUER || 'https://api.sonavera.ai/op',
  clientId: process.env.OIDC_CLIENT_ID!,
  expectedNonce: nonce,
  expectedTenantSubjectId: '019141c4-9ad4-4f37-8f09-364744c459d7',
  expectedFlow: 'verify',
});

const flow = payload['https://sonavera.ai/flow'];
const handshakeId = payload['https://sonavera.ai/handshake_id'];
const ceremony = payload['https://sonavera.ai/ceremony'];
const evidence = payload['https://sonavera.ai/evidence'];

For enroll/verify tokens, expect https://sonavera.ai/tenant_subject_id in addition to the flow claims; OIDC sub remains Sonavera's subject identifier. For uniqueness tokens, expect acr=urn:sonavera:acr:uniqueness, flow=uniqueness, https://sonavera.ai/scope_subject_id, and https://sonavera.ai/scope_key. For liveness tokens, expect acr=urn:sonavera:acr:liveness, flow=liveness, and https://sonavera.ai/interaction_id. Liveness and uniqueness tokens also carry a client-scoped, one-way https://sonavera.ai/deletion_subject_binding; the validator compares it with expectedTenantSubjectId without exposing the raw deletion subject. The validator rejects unknown deliverable outcomes, retired subject_ref or interaction_ref claims, and any identifier claim that is not applicable to the selected flow.

Public-client SPA quick start

Start a browser-managed Authorization Code + PKCE transaction:

import { startSonaveraPublicClientOidc } from '@sonavera-ai/tenant-sdk';

const transaction = await startSonaveraPublicClientOidc({
  issuer: 'https://api.sonavera.ai/op',
  clientId: 'tenant-spa-client',
  redirectUri: 'https://spa.example.com/',
  type: 'verify',
  tenantSubjectId: '019141c4-9ad4-4f37-8f09-364744c459d7',
});

sessionStorage.setItem('sv_txn', JSON.stringify(transaction));
window.location.assign(transaction.authorizeUrl);

After the browser returns, exchange the code directly with Sonavera:

import { exchangeSonaveraAuthorizationCode } from '@sonavera-ai/tenant-sdk';

const transaction = JSON.parse(sessionStorage.getItem('sv_txn')!);
const params = new URLSearchParams(window.location.search);
const returnedState = params.get('state');

if (!transaction?.state || returnedState !== transaction.state) {
  throw new Error('Public-client callback state mismatch.');
}

const tokens = await exchangeSonaveraAuthorizationCode({
  issuer: 'https://api.sonavera.ai/op',
  clientId: 'tenant-spa-client',
  redirectUri: 'https://spa.example.com/',
  code: params.get('code')!,
  codeVerifier: transaction.codeVerifier,
});

The browser should then forward tokens.id_token and the expected nonce or action challenge back to the tenant backend. The tenant backend validates that signed Sonavera token locally before allowing the protected action.

Common integration rules

  • Keep the tenant API key and API secret on the backend only.
  • Treat API redirect browser query parameters as advisory; the backend result lookup is authoritative.
  • Validate resultToken with backend-owned expected context only when relying on the signed token independently; never validate it in the browser or infer trusted expectations from its unverified claims.
  • Use type: 'uniqueness' for the API redirect uniqueness Evidence Index profile with normalized high-is-good identity-uniqueness measurements.
  • Use tenant-mediated deletion only with exact tenant-owned identifiers after the tenant has authenticated or identified the person; it is not a biometric search API.
  • When using webhooks, verify x-sonavera-signature against the raw request body before parsing JSON.
  • Webhooks are delivered at-least-once. Deduplicate on eventId and keep backend retrieval as a recovery path if a callback is missed.
  • Use exact registered OIDC redirect URIs.
  • For public-client OIDC, the browser origin must be registered on the OIDC client as an allowed browser origin.
  • For verify, the supplied tenant subject ID should already refer to an enrolled user.
  • Persist state, nonce, and code_verifier server-side for backend/BFF OIDC.
  • For public-client OIDC, the browser may hold state and code_verifier, but the tenant backend should still own or verify the nonce or action-binding challenge used for the protected action.

Sample tenant rule

Sonavera signed artifacts describe ceremony lifecycle and evidence. They do not return Sonavera-owned allow, block, review, or retry actions.

A demo tenant can validate the exact verification artifact and then apply its own threshold:

import {
  parseSonaveraEvidenceArtifact,
  type SonaveraApiRedirectResultData,
} from '@sonavera-ai/tenant-sdk';

function meetsDemoVerificationRule(
  result: SonaveraApiRedirectResultData,
): boolean {
  if (!result.terminal || result.type !== 'verify') return false;
  if (result.result.ceremony.status !== 'completed') return false;
  if (result.result.sensorData?.sufficient !== true) return false;
  if (result.result.deliverableOutcome !== null) return false;
  if (result.result.claims?.['https://sonavera.ai/flow'] !== 'verify') return false;

  const artifact = parseSonaveraEvidenceArtifact(
    result.result.claims['https://sonavera.ai/evidence'],
    'verify',
  );
  return artifact.index_score >= 80;
}

// The threshold and resulting action are demo tenant logic, not Sonavera guidance.

For production, choose thresholds and fallback behavior from the tenant's own risk model, product UX, and compliance posture. Treat index_score as a 0..100 evidence index under the signed profile, not a probability.

Release and publishing

SDK releases are published from the repo workflow Publish Tenant SDK (.github/workflows/publish-tenant-sdk.yml). Publishing is automatic when a commit lands on main with a changed integrations/typescript-sdk/package.json version. That version bump PR is the release approval. A main push that changes the publication workflow also checks the exact repository version in npm: it skips only when that version is already published and retries publication when the previously approved version is still absent. Registry errors other than a confirmed not-found response fail the workflow. After a real publish succeeds, the publication workflow dispatches Deploy Public Website on then-current main, so its workflow definition, source tree, and release metadata describe the same commit. The publisher correlates the dispatched run, waits for it to finish, and fails if website validation, deployment, or production smoke checks fail. Dry runs, skipped publishes, and failed publishes do not dispatch the deployment.

Before merging a version bump:

  • Confirm the public SDK contract still matches docs/tenant-integration.md and docs/tenant-sdk-quickstart.md.

  • Confirm the v4 producer and consumer contracts pass validation. SDK 0.12.0 supports both published v3 and current v4 artifacts, so publish/install it before switching producers to v4.

  • Bump the package version in integrations/typescript-sdk/package.json.

  • Run the local preflight:

    make tenant-sdk-release-check
  • Optionally run the GitHub workflow dry run:

    make tenant-sdk-publish-dry-run
  • Confirm npm trusted publishing remains configured for package @sonavera-ai/tenant-sdk against repository sonavera/sonavera, workflow file .github/workflows/publish-tenant-sdk.yml, and environment npm-publish.

  • Merge the version bump to main; the workflow publishes that version automatically.

  • If an approved repository version did not reach npm, fix the publication workflow without inventing another version bump. The workflow change triggers an exact-version registry check, retries the missed release, and dispatches the website deployment at current main after npm publication succeeds.

Manual fallback remains available:

make tenant-sdk-publish CONFIRM=publish

The workflow uses npm trusted publishing for real publishes and verifies that the package tarball contains only LICENSE, README.md, package.json, and built dist/* artifacts. npm provenance is intentionally disabled while the GitHub source repository is private because npm rejects provenance bundles from private GitHub repositories.

For the profile-v4 audio-provider cutover:

  1. Verify SDK 0.12.0 and its generated website documentation were published from the merged release SHA. Upgrade tenant readers before services emit v4.
  2. Use the normal coordinated maintenance/release procedure to pause new ceremonies and drain active ones. Provision the selected provider credential and activate the complete legal packet before enabling its processing.
  3. Release ML PAD and Orchestrator with the same provider selection. Retain existing data and immutable published v3 profile documents.
  4. Verify one complete ceremony through v4 signed evidence and tenant rendering, plus continued validation of a published v3 fixture.

The provider-specific release prerequisites and candidate legal packet are in docs/operations/audio-pad-providers.md in the source repository. Human merge owns SDK publication; production service release and legal activation remain separate operator actions.

Errors

All helpers throw SonaveraIntegrationError with a machine-readable code and, when relevant, an HTTP status and structured details.

import { SonaveraIntegrationError } from '@sonavera-ai/tenant-sdk';

try {
  // SDK call
} catch (error) {
  if (error instanceof SonaveraIntegrationError) {
    console.error(error.code, error.status, error.details, error.message);
  }
  throw error;
}

Reference material

This README is the npm-published integration reference for the current package. Public long-form documentation will be linked here when the public docs target is available.