Auth & API keys
Authentication mirrors the web widget exactly. This page is the same for both SDKs — only the
init snippets differ. (For the product-wide map of every credential — pk_, vb_pk_, the ingest
secret — see API keys.)
The VoiceBot key (one key, both surfaces)
A tenant has one VoiceBot key = a paired tenant_id plus a shared secret, created when you pair the
plugin (stored AES-GCM on the backend in provider_connections). The same key authorizes both your
website widget (origin-bound) and your mobile app (bundle-id-bound). You do not provision a separate mobile
credential.
The SDK never holds the shared secret in the app binary. It obtains a short-lived session token (a
JWT) and connects with that. There are two ways to get the session token — pick one via the SDK's
TokenProvider abstraction.
┌─────────────────────────── Mode A (recommended) ───────────────────┐
│ shared secret stays here │
┌──────────┐ │ ┌──────────────────┐ HMAC-signed ┌──────────────────────────┐ │
│ Your app │ ──────▶│ │ Your backend │ ─────────────▶ │ POST /api/v1/mobile/ │ │
│ + SDK │ token │ │ (holds secret) │ issue-token │ issue-token │ │
└──────────┘ req │ └──────────────────┘ ◀───── JWT ─── └──────────────────────────┘ │
▲ └────────────────────────────────────────────────────────────────────┘
│
│ ┌──────────────────────────── Mode B (no backend) ──────────────────────────────┐
└───│ SDK ── vb_pk_ + app_id ─▶ POST /api/v1/mobile/issue-token-public ── JWT ─▶ SDK │
└────────────────────────────────────────────────────────────────────────────────┘
Mode A — server-minted token (recommended)
Your backend (the one already serving the app its products and categories) holds the shared secret and mints the session token, exactly like the web widget. The secret never ships in the binary — this is true web-parity and the recommended path.
Your backend signs POST /api/v1/mobile/issue-token with the ingest HMAC:
POST /api/v1/mobile/issue-token
X-VoiceBot-Tenant-Id: <uuid>
X-VoiceBot-Timestamp: <epoch seconds> # ±300s window
X-VoiceBot-Nonce: <single-use, 600s TTL>
X-VoiceBot-Signature: <hex>
X-VoiceBot-App-Id: <ios bundle id / android package>
{ "lang": "uk" }
200 OK
{ "session_token": "<JWT>", "expires_at": <epoch>, "refresh_after": <epoch> }
The signature preimage (exact — do not guess)
The signature is an HMAC-SHA256 over a newline-joined canonical string. This is the exact preimage the
backend verifies; an earlier draft guessed "<ts>.<nonce>.<body>" — that is wrong.
sig = HMAC_SHA256(secret, "{METHOD}\n{path}\n{timestamp}\n{nonce}\n{sha256_hex(raw_body)}").hex()
# e.g.
# "POST\n/api/v1/mobile/issue-token\n1718000000\n<nonce>\n<sha256hex(body)>"
#
# body_sha256 = sha256(raw_request_body_bytes).hexdigest()
# empty body → sha256(b"") hex
The app fetches the resulting session_token from your backend. Wire it up via a TokenProvider:
- React Native
- Flutter
const client = VoicebotClient.init({
baseUrl: 'https://api.monoverse.tech',
auth: {
mode: 'custom',
tokenProvider: {
async issueToken(lang) {
const r = await fetch('https://shop.example.com/voicebot/token', {
method: 'POST',
body: JSON.stringify({ lang }),
});
return r.json(); // { sessionToken, expiresAt, refreshAfter }
},
},
},
});
The SDK also ships an HmacTokenProvider that performs the signing itself. Use it only where holding the
shared secret is acceptable (a trusted server runtime or local development) — never in a shipped binary.
The SDK's MobileIssueTokenProvider builds the exact canonical preimage and signs the request for you. The
default IssueTokenSigner is the real HMAC-SHA256 — no wiring needed; it is injectable only if you delegate
to a hardware-backed key.
final client = VoicebotClient.init(
baseUrl: 'https://api.monoverse.tech',
tokenProvider: MobileIssueTokenProvider(
issueTokenUrl: Uri.parse('https://your-backend.example/api/v1/mobile/issue-token'),
credential: const AppCredential(
tenantId: '…', // your VoiceBot tenant id
appId: 'com.yourco.app', // iOS bundle id / Android package
sharedSecret: '…', // SERVER-SIDE ONLY — do NOT ship in the app
),
poster: myHttpPoster, // inject your HTTP client (keeps the SDK dependency-free)
),
);
The sharedSecret must live on a server runtime. Do not embed it in a shipped app — use Mode B (publishable
key) for apps without a backend.
Mode B — publishable key (no backend)
For apps without a backend. Embed a publishable key vb_pk_… — an identifier, not the secret,
safe to ship (like a Stripe pk_). Get it at Cabinet → Integrations → Mobile app.
The SDK calls the public endpoint:
POST /api/v1/mobile/issue-token-public
{
"api_key": "vb_pk_…",
"app_id": "<bundle/package>",
"attestation": "<App Attest / Play Integrity, optional>",
"lang": "uk"
}
200 OK { "session_token": "<JWT>", "expires_at": <epoch>, "refresh_after": <epoch> }
4401 key inactive
4403 app_id not in allowlist
- React Native
- Flutter
const client = VoicebotClient.init({
baseUrl: 'https://api.monoverse.tech',
auth: { mode: 'publishableKey', apiKey: 'vb_pk_…', appId: 'com.yourco.app' },
});
final client = VoicebotClient.init(
baseUrl: 'https://api.monoverse.tech',
tokenProvider: PublishableKeyTokenProvider(
issueTokenUrl: Uri.parse('https://api.monoverse.tech/api/v1/mobile/issue-token-public'),
publishableKey: 'vb_pk_…',
appId: 'com.yourco.app',
poster: myHttpPoster,
),
);
Why a public key is safe
The security boundary for Mode B is not the secrecy of the key — it is the app_id allowlist + the
connection status. An attacker who pulls your vb_pk_ out of your APK and calls the public endpoint with a
different app_id gets 4403 — no token. With an app_id that is on your allowlist (i.e. they
repackaged your own bundle id) they get a token for your tenant's public bot — which is exactly what
the key is for. Cross-tenant access is structurally impossible: tenant_id is resolved server-side from the
key and placed in the signed JWT claim; it is never taken from a client parameter.
The session token
session_token is an HS256 JWT (same mechanism as the web widget) with claims including tenant_id,
shop_id, app_id, language, channel: "mobile", and allowed_app_ids (which replaces the web token's
allowed_origins).
- TTL ≈ 3600s, refresh-after ≈ 1800s. The SDK re-issues before
refresh_after. - Stored in the Keychain (iOS) / EncryptedSharedPreferences (Android) — never logged.
Billing kill-switch (revoke)
The backend mints tokens only while the connection is active (provider_connections.status == "connected"). This is the "account alive → key works" lever:
- Flip the status (subscription lapsed, over a limit) → every token request fails and active sessions
are revoked within ~30s (close code
4401). - No extra wiring on your side — it reuses the same status the web widget is gated on.
Attestation (optional hardening)
App Attest (iOS) / Play Integrity (Android) is optional hardening layered on Mode B, configurable
per-connection (attestation_required). It is not the primary gate — the primary gate is key +
app_id allowlist + connection status (web parity). Enable it for tenants who want stricter
repackage/emulator resistance.
Transport security
- WSS only. The SDK rejects
ws://(except localhost for development). - Certificate pinning is opt-in and rotation-aware.
See also
- Wire protocol → Auth — the same contract at the byte level.
- Lifecycle — what happens when a token expires or a session is revoked.