@sonavera-ai/tenant-sdk
v0.12.0
Published
TypeScript helpers for Sonavera tenant OIDC, API redirect, webhook, and deletion integrations.
Downloads
295
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, 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.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.
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[] }, 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). The field is required; an omitted or 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. 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 asbad_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 carriesfalse.https://sonavera.ai/deliverable_outcome(no_enrollment|no_verification|no_liveness_result|no_uniqueness_result) — a completed enroll/verify withoperation_performed=falsecarries 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 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 evidence detail, inspect
result.result.claims?.['https://sonavera.ai/evidence'] and validate it with
parseSonaveraEvidenceArtifact. Schema 1 has one exact shape:
flowselects one generated profile: enrollment, verification, uniqueness, or liveness.profilecontains the profile ID, positive integer version, issuer URI, and pinned SHA-256 digest.index_scoreis an integer from0through100. It is compensatory evidentiary support, not a probability or Sonavera action recommendation. Display it without a percent sign.index_measurements_statusismeasurements_complete,measurements_partial, ormeasurements_incomplete.evidence_axescontains ordered axes with normalized measurements and exact allocated/earned points.supplemental_checkscontains 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, ormeasurement_absent; count-based checks also carry the profile target and collected count. - Optional
detailsvalues 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: currentlyhandshake.result.v1x-sonavera-event-id: the webhook event UUID; require it to match the signed bodyeventId, then use the body value as the transactional idempotency keyx-sonavera-timestamp: Unix timestamp in secondsx-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': passtenantSubjectId; the helper sends it as both the required deletion association and standard OIDClogin_hint.type: 'liveness': passtenantSubjectIdplusinteractionId; 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': passtenantSubjectId,scopeSubjectId, and first-classscopeId; 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
resultTokenwith 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-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 tenant subject ID 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 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.mdanddocs/tenant-sdk-quickstart.md.Confirm the v4 producer and consumer contracts pass validation. SDK
0.12.0supports 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-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.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
mainafter npm publication succeeds.
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.
For the profile-v4 audio-provider cutover:
- Verify SDK
0.12.0and its generated website documentation were published from the merged release SHA. Upgrade tenant readers before services emit v4. - 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.
- Release ML PAD and Orchestrator with the same provider selection. Retain existing data and immutable published v3 profile documents.
- 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.
