@sonavera-ai/tenant-sdk
v0.5.0
Published
TypeScript helpers for Sonavera tenant OIDC, API redirect, webhook, and deletion integrations.
Downloads
946
Maintainers
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 and authoritative result retrieval
- tenant-mediated deletion requests for active Sonavera-held ceremony artifacts
- 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 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 @sonavera-ai/tenant-sdkThe package ships built ESM JavaScript and declaration files, so it can be consumed from standard NodeNext TypeScript projects without bundler-only module resolution.
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.
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.
API redirect quick start
Start a handshake on the backend:
import { startSonaveraApiRedirectHandshake } from '@sonavera-ai/tenant-sdk';
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!,
type: 'enroll',
userId: 'external-user-123',
expires: 180,
returnUrl: 'https://tenant.example.com/sonavera/return',
});
// Redirect the browser to handshake.verificationUrlIf 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.
Only one active semantic ceremony is admitted per tenant: enroll/verify share
the external userId, liveness uses interactionRef, and uniqueness uses
scopeId plus subjectRef. 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 does not yet define a request-id idempotency contract, so do not replay
an ambiguous start and assume it will return an earlier handshake.
For uniqueness checks, pass an opaque subjectRef and a first-class scopeId:
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!,
type: 'uniqueness',
subjectRef: 'participant-123',
scopeId: '550e8400-e29b-41d4-a716-446655440001',
expires: 180,
returnUrl: 'https://tenant.example.com/uniqueness/return',
});
// uniquenessHandshake.scopeId identifies the bound uniqueness scope.Create that scope through the tenant/admin scope API or dashboard before starting the handshake. 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. Do not implement a separate rolling browser/network window in the tenant integration.
Uniqueness evidence keeps browser.fingerprint_similarity.v1 and
network.fingerprint_similarity.v1 separate. Under policy 2026-07-12.1
they are contextual and weight zero. Handle their check-keyed status,
optional score, reason, and safe details instead of treating capture
presence or a missing candidate as score zero. A first participant normally
returns not_measured with no_eligible_scope_candidate.
Every flow may also include browser.authenticity.v1 and
network.authenticity.v1. Keep these checks separate from each other and from
continuity. Their optional 0..1 scores are uncalibrated, contextual,
zero-primary measurements of consistency with ordinary interactive use—not
percentages/probabilities of authenticity, proof of human participation, or
human/bot verdicts. Respect partial, not_measured, and unavailable statuses
instead of defaulting missing evidence to a favorable score.
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!,
type: 'liveness',
interactionRef: 'free-tier-request-118-attempt-1',
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.
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[] }, ornullwhen collection was never evaluated (the ceremony ended before capture — e.g. consent declined or expired untouched — or failed on a transport/internal error).nullis never a statement about the data; only a present object carries a sufficiency reading. Whensufficientisfalse,reasonis one ofno_video_frames,no_face_detected,insufficient_face_frames, andsensors[]gives per-critical-sensor detail (sensor,framesCollected,sufficient,reason). A response missing thesensorDatakey entirely (an older server) also reads asnull; a present-but-malformed block is rejected asbad_upstream_payload. The SDK never fabricates a sufficiency value.deliverableOutcome:no_enrollment|no_verification|no_liveness_result|no_uniqueness_result|null. Non-null only when a completed ceremony did not produce its deliverable (insufficient critical data, no enrolled reference for a verify, or an enrollment held back by the ceremony integrity gate). It describes whether the operation happened — never a verdict about the person. A newer server may add outcome values; the SDK passes unknown non-null values through verbatim, so always treat any non-null value as "the deliverable does not exist".
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 two extra Sonavera claims so you can tell a performed operation from a
completed-but-not-performed one without a second fetch:
https://sonavera.ai/operation_performed(boolean; enroll/verify only).https://sonavera.ai/deliverable_outcome(no_enrollment|no_verification|no_liveness_result|no_uniqueness_result) — present only when the deliverable was NOT produced.
const claims = result.result.claims; // decoded/validated signed claims
if (claims?.['https://sonavera.ai/deliverable_outcome']) {
// Completed ceremony, but no verification/enrollment happened — do not grant access.
}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 UUIDterminal_status: one ofCOMPLETED,FAILED,CONSENT_DECLINED,USER_ENDED_SESSION, orEXPIREDflow:enroll,verify,uniqueness, orlivenesswhen known
Use these fields only to restore tenant UX state. The backend result lookup is authoritative for product decisions.
For sensor-level detail, inspect result.result.claims?.['https://sonavera.ai/evidence']. The evidence claim (schema 1) contains a policy-composed score and per-check normalized scores/weights:
score.valueis normalized from0..1, but is not a probability.- Same-proposition checks use a weighted mean; independent required axes use minimum, so strong evidence on one dimension cannot compensate for weak evidence on another. The lowest required axis explains the primary score.
subscoresexposes the policy graph's named axes:baseline_usability,identity_alignment,scoped_distinctness,challenge_integrity,media_integrity,presentation_integrity, plus measured contextualbrowser_continuity,network_continuity,browser_authenticity, andnetwork_authenticity.checks[]distinguishes measured, partial, timed-out, unavailable, and error states.checks[].details(optional) carries a structured, tenant-safe rationale:reasons(coarse reason codes),matched_on(coarse component names that matched the enrolled baseline), andcontext(informational capture-context labels that never carry score weight). Details never include raw fingerprints, signal capsules, or IP-derived identifiers.
A verify-flow evidence claim looks like:
{
"schema": 1,
"schema_uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-schema#v1",
"policy": {
"id": "sonavera-live-multisignal",
"version": "2026-07-12.1",
"uri": "https://api.sonavera.ai/.well-known/sonavera-evidence-policies#sonavera-live-multisignal/2026-07-12.1"
},
"score": { "name": "verification_evidence", "value": 0.9 },
"subscores": {
"identity_alignment": 0.93,
"challenge_integrity": 0.9,
"media_integrity": 0.95,
"presentation_integrity": 0.9,
"browser_continuity": 0.82,
"network_continuity": 0.88,
"browser_authenticity": 0.75,
"network_authenticity": 0.70
},
"checks": [
{ "id": "face.match.v1", "status": "measured", "score": 0.95, "weight": 0.4 },
{
"id": "network.fingerprint_similarity.v1",
"status": "measured",
"score": 0.88,
"weight": 0,
"details": {
"context": {
"geo_country": "DE",
"asn": "AS3320",
"ip_network_class": "residential_or_business"
}
}
},
{
"id": "browser.fingerprint_similarity.v1",
"status": "measured",
"score": 0.82,
"weight": 0,
"details": {
"reasons": ["visitor_id_changed"],
"matched_on": ["platform_family", "browser_family", "timezone"]
}
},
{
"id": "browser.authenticity.v1",
"status": "measured",
"score": 0.75,
"weight": 0,
"details": { "reasons": ["interactive_characteristics_observed"] }
},
{
"id": "network.authenticity.v1",
"status": "measured",
"score": 0.70,
"weight": 0,
"details": {
"reasons": ["interactive_characteristics_observed", "residential_or_business_network_observed"],
"context": { "ip_network_class": "residential_or_business", "tls_client_class": "unclassified" }
}
}
]
}Network checks may carry context rationale derived at capture time (geo_country, asn, ip_network_class with values hosting or residential_or_business, and tls_client_class with values known_automation_tool or unclassified). Browser checks may carry an automation_signals context label (comma-joined codes such as webdriver, headless_user_agent, no_languages, capsule_inconsistent). Displayed labels carry no independent weight; policy 2026-07-12.1 may use the same sealed coarse classifications to derive the separate authenticity check score.
Tenants should apply their own risk policy to the evidence instead of treating Sonavera as an allow/block authority.
Privacy receipt quick start
Participants see their privacy receipt on the ceremony completion screen and
can download it there; nothing receipt-related persists in their browser. For
durable access, correlate the ceremony via the
https://sonavera.ai/handshake_id ID-token claim and fetch the receipt record
from a tenant backend, then surface it inside your own authenticated UI:
import { getSonaveraHandshakeReceipt } from '@sonavera-ai/tenant-sdk';
const receipt = await getSonaveraHandshakeReceipt({
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: 'handshake-uuid-from-id-token-claim',
});
// receipt.receiptId, receipt.consentedAt, receipt.providersDisclosed, ...Verify the handshake belongs to the signed-in user before serving the receipt.
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. This deletes the tenant-scoped external user and cascades deletion through that user's handshakes, consents, and sensitive ceremony artifacts. It is also the route for participant deletion requests: participants quote the receipt ID from their downloaded privacy receipt to the tenant.
import { deleteSonaveraExternalUser } from '@sonavera-ai/tenant-sdk';
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!,
externalUserId: 'external-user-123',
requestId: 'privacy-ticket-123',
});
if (deletion.status === 'deleted') {
// The external user and associated Sonavera-held records were deleted.
}
if (deletion.status === 'not_found') {
// No tenant-owned external user matched that identifier.
}The tenant API key does not support deleting individual handshakes or individual artifacts.
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 {
type SonaveraHandshakeWebhookEvent,
verifySonaveraWebhookSignature,
} from '@sonavera-ai/tenant-sdk';
const rawBody = await request.text();
const eventId = request.headers.get('x-sonavera-event-id') || '';
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,
eventId,
replayGuard: async ({ key, expiresAt }) => {
// Atomically insert `key` with an expiry in your database/cache.
// Return false if the key already exists.
return await reserveWebhookReplayKey(key, expiresAt);
},
});
if (!verified) {
throw new Error('Invalid Sonavera webhook signature');
}
const event = JSON.parse(rawBody) as SonaveraHandshakeWebhookEvent;
if (event.data.terminal && event.data.result.ceremony.status === 'completed') {
// Apply tenant policy to event.data.result.claims?.['https://sonavera.ai/evidence'].
}Sonavera sends webhook requests with these headers:
x-sonavera-event: currentlyhandshake.result.v1x-sonavera-event-id: the webhook event UUID; deduplicate on this valuex-sonavera-timestamp: Unix timestamp in secondsx-sonavera-signature:v1=<hex hmac sha256>
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. Provide replayGuard backed by an atomic database/cache
insert keyed by x-sonavera-event-id to reject duplicate deliveries.
The signature input is the timestamp, a literal period, and the exact raw body:
v1=hex(hmac_sha256(secret, timestamp + "." + rawBody))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=65ec15228a76e6af8fca6b24c80813621b89c0b64a1c014327c008d03e774a44';
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","externalUserId":"external-user-1","subjectRef":null,"scopeKey":null,"scopeId":null,"interactionRef":null,"status":"COMPLETED","terminal":true,"expiresAt":"2026-03-29T18:05:00.000Z","completedAt":"2026-03-29T18:00:00.000Z","result":{"ceremony":{"status":"completed"},"claims":null,"resultToken":null,"failure":null}}}';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',
loginHint: 'external-user-123',
state,
nonce,
codeChallenge,
});OIDC flow-specific inputs:
type: 'enroll' | 'verify': passloginHint.type: 'liveness': passinteractionRef; nologinHintis required.type: 'uniqueness': passsubjectRefand first-classscopeId; nologinHintis required.
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',
subjectRef: 'participant-123',
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,
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 uniqueness tokens, expect acr=urn:sonavera:acr:uniqueness, flow=uniqueness, https://sonavera.ai/subject_ref, and https://sonavera.ai/scope_key. For liveness tokens, expect acr=urn:sonavera:acr:liveness, flow=liveness, and https://sonavera.ai/interaction_ref.
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',
loginHint: 'external-user-123',
});
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.
- Use
type: 'uniqueness'for API redirect joint uniqueness evidence with granular scoped-similarity checks. - 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-signatureagainst the raw request body before parsing JSON. - Webhooks are delivered at-least-once. Deduplicate on
eventIdand 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 external user identifier should already refer to an enrolled user. - Persist
state,nonce, andcode_verifierserver-side for backend/BFF OIDC. - For public-client OIDC, the browser may hold
stateandcode_verifier, but the tenant backend should still own or verify the nonce or action-binding challenge used for the protected action.
Sample tenant policy
Sonavera signed artifacts describe ceremony lifecycle and evidence. They do not
return Sonavera-owned allow, block, review, or retry recommendations.
Before thresholding, validate the completed ceremony, required sensor data and
deliverable state, expected flow, expected primary score name, and policy
version. The SDK accepts the immutable 2026-07-04.1 and 2026-07-11.1
archives plus the current 2026-07-12.1 policy, and rejects a score name that
does not match its flow and policy version.
A simple demo policy can start with:
import type { SonaveraApiRedirectResultData } from '@sonavera-ai/tenant-sdk';
const CURRENT_POLICY = {
id: 'sonavera-live-multisignal',
version: '2026-07-12.1',
} as const;
const CURRENT_SCORE_NAME = {
enroll: 'enrollment_evidence',
verify: 'verification_evidence',
uniqueness: 'uniqueness_evidence',
liveness: 'liveness_evidence',
} as const;
function readCurrentEvidenceScore(
result: SonaveraApiRedirectResultData,
expectedFlow: keyof typeof CURRENT_SCORE_NAME,
): number | null {
if (!result.terminal || result.type !== expectedFlow) return null;
if (result.result.ceremony.status !== 'completed') return null;
if (result.result.sensorData?.sufficient !== true) return null;
if (result.result.deliverableOutcome !== null) return null;
const claims = result.result.claims;
if (claims?.['https://sonavera.ai/flow'] !== expectedFlow) return null;
const evidence = claims['https://sonavera.ai/evidence'];
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) return null;
const claim = evidence as {
policy?: { id?: unknown; version?: unknown };
score?: { name?: unknown; value?: unknown };
};
if (claim.policy?.id !== CURRENT_POLICY.id) return null;
if (claim.policy?.version !== CURRENT_POLICY.version) return null;
if (claim.score?.name !== CURRENT_SCORE_NAME[expectedFlow]) return null;
const value = claim.score.value;
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
? value
: null;
}
const DEMO_VERIFY_SCORE_THRESHOLD = 0.8;
const verificationEvidence = readCurrentEvidenceScore(result, 'verify');
const accepted = verificationEvidence !== null
&& verificationEvidence >= DEMO_VERIFY_SCORE_THRESHOLD;
// This threshold and action are demo tenant policy, not Sonavera guidance.For production, choose thresholds and fallback behavior from the tenant's own
risk model, product UX, and compliance posture. Treat score.value as a
normalized policy score, 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. The workflow skips
main-push runs when the SDK package files change without a version bump.
Before merging a version bump:
Confirm the public SDK contract still matches
docs/tenant-integration.mdanddocs/tenant-sdk-quickstart.md.Bump the package version in
integrations/typescript-sdk/package.json.Run the local preflight:
make tenant-sdk-release-checkOptionally run the GitHub workflow dry run:
make tenant-sdk-publish-dry-runConfirm npm trusted publishing remains configured for package
@sonavera-ai/tenant-sdkagainst repositorysonavera/sonavera, workflow file.github/workflows/publish-tenant-sdk.yml, and environmentnpm-publish.Merge the version bump to
main; the workflow publishes that version automatically.
Manual fallback remains available:
make tenant-sdk-publish CONFIRM=publishThe 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.
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.
