Tenant isolation
A merchant is a tenant. Each tenant has a single VoiceBot identity, but it is reached through
three different credentials, one per plane (see Architecture). They are not
interchangeable: each is scoped to its plane, flows in its own direction, and is verified differently.
The invariant across all three: the client never asserts which tenant it is — the backend derives
tenant_id from the credential and binds it server-side.
The three models at a glance
| 1 · Backend ingest | 2 · Frontend widget | 3 · Native mobile | |
|---|---|---|---|
| Direction | Your server → VoiceBot (push) | Browser ↔ VoiceBot (live) | App ↔ VoiceBot (live) |
| Credential | HMAC shared secret (sk_-class, per tenant) | Publishable key pk_… | vb_pk_ (Mode B) or HMAC secret (Mode A) → session token |
| Where it lives | Your server, encrypted at rest | Shipped in the browser bundle | Shipped in the app binary |
| Secret? | Yes — never logged or in a response | No — public, origin-locked | No — public; token is short-lived |
| Bound by | tenant_id header + signature over the request | Origin allow-list | Origin/app-id allow-list, token claim |
1. Backend ingest — HMAC signed push
The server-to-server catalog feed is authenticated with HMAC-SHA256. Pairing mints a per-tenant
shared secret (32 random bytes) which your server stores encrypted at rest and never logs or
returns. Each request sends X-VoiceBot-Tenant-Id and a signature over
METHOD\npath\nts\nnonce\nbody_sha256. Replay is blocked two ways: the timestamp must be within
±300s of server time, and the nonce is single-use for 600s per tenant. Because the nonce is
consumed even on an error, signed requests are not blindly retried. Full detail in
Auth & HMAC.
2. Frontend widget — origin-locked publishable key
The browser widget authenticates with a publishable key pk_… — a public, origin-locked
identifier (like a Stripe pk_), safe to ship in markup. The widget self-mints: it exchanges the
pk_ plus the browser-set Origin for a short-lived session token. The backend resolves the tenant
from the pk_ and signs tenant_id into the token claim — the client never sends it. The security
boundary is the origin allow-list (exact match, no wildcards), checked at the token mint (403
on miss) and again at the WebSocket upgrade (4403 on miss).
3. Native mobile — WebSocket session token
A native app connects with a short-lived session token, then opens the voice/text WebSocket with
it. As on the web, the token carries the tenant claim; the device never asserts a tenant_id. There
are two ways to mint that token: Mode A (recommended) — your backend, holding the same HMAC
shared secret it uses for ingest, server-mints the token, so no key ships in the binary; Mode
B — apps without a backend embed a publishable key and self-mint. The mobile publishable key is
prefixed vb_pk_ (distinct from the web widget's pk_ — don't conflate them); it is an
identifier, not a secret, bound to your app bundle id / package, and safe in the app binary. See
the mobile auth guide.
What the merchant is responsible for
VoiceBot enforces the boundaries, but two things are yours to manage:
- Keys and secrets. Keep the HMAC shared secret server-side and encrypted; rotate it by
re-pairing if it could have leaked. The
pk_is public by design — you don't protect it, you constrain it with origins. - Origins and app ids. The allow-list is the security boundary for the public-key planes. List
every exact origin (including
www, subdomains, and dev ports) and every app bundle id. A site or app not on the list is denied — that is the intended behaviour, not a bug.
The shared secret signs ingest and never appears in a browser or app. The pk_ authorizes the
widget/app and can never sign ingest. Pairing a backend does not expose or replace your public key,
and vice versa.