Skip to main content
Version: next

Consent contract

The widget keeps a visitor identifier for conversation continuity and rating attribution. Which storage tier that identifier gets — session-scoped or persistent — is decided by a consent signal the merchant delivers. This page is the single reference for that signal: its three inputs, its exact value set, the precedence rule, what each tier stores, what stops working without consent, the cookie facts you need for your own policy and CMP category mapping, and the trust model behind it all.

The three inputs

All three inputs resolve to the same state through one storage boundary — three inputs never produce three states.

InputWhere it livesValue setWhen it is read
data-consentattribute on the embed <script> tagexactly granted / deniedat boot, by the widget loader
CreateWidgetConfig.consentconsent option on the programmatic createWidget({ ... }) callexactly granted / deniedat boot, when the programmatic entry resolves its config
window.VoiceBot.setConsent(granted: boolean)runtime global the bundle installsexactly true (granted) / false (denied)any time — before or after boot, and whether or not a widget ever mounts

The runtime entry point is installed at module scope before the publishable-key exchange and before startWidget. A CMP callback that fires on an excluded page, on a disallowed origin, while the key exchange is still in flight, or after the exchange failed is still defined, does not throw, and records the decision, which any widget that mounts afterwards — on this page or a later one — reads first.

The two boot inputs are peers: a page may deliver both, and they are read through the same resolution.

Tokens and the malformed branch

granted and denied are the only accepted tokens, compared case-sensitively. They are not the truthy convention of the neighbouring options — data-eager="1" and data-prewarm="1" use '1' || 'true', and that convention is deliberately not what data-consent accepts:

DeliveredResolution
data-consent="granted" / createWidget({ consent: 'granted' }) / setConsent(true)granted — persistent tier
data-consent="denied" / createWidget({ consent: 'denied' }) / setConsent(false)denied — session tier
anything else — absent, empty, "1", "true", "yes", any other casing ("Granted"), any non-boolean argument to setConsentmalformed branch — session tier, fail-closed

Where a page delivers both boot inputs and they disagree — data-consent="granted" on the tag plus createWidget({ consent: 'denied' }) — the result is the session tier, not a tie-break rule you would have to remember.

Precedence: the stored decision wins

The invariant, stated once: a runtime setConsent call is the only input that writes the stored decision, and it supersedes both boot inputs until a later runtime call replaces it.

  • The decision is recorded under vb_consent in localStorage.
  • Neither boot input is ever written to that record — only a runtime setConsent call creates or replaces it.
  • So a hardcoded boot value cannot re-grant a withdrawal: a merchant SPA that re-invokes createWidget({ consent: 'granted' }) on every route change does not overwrite a setConsent(false) decision, and a fresh bundle evaluation (SPA re-inject, second bundle, new tab) re-reads the record rather than the boot input.
  • The record lives in localStorage, never sessionStorage, so a withdrawal is at least as durable as the year-long storage it revokes: a second tab opened under a pinned data-consent="granted" still sees the withdrawal.
  • Repeating the same decision changes nothing — a second grant writes no second identifier, and a withdrawal with nothing persisted is not an error.

What each tier stores

The identifier is minted at the visitor's first engagement (opening the widget, activating voice or chat, or sending a message) — never at boot and never on a page where the widget is never touched.

TierCookielocalStoragesessionStorage
Session — no signal, malformed signal, or deniedvb_visitor with a session directive: Path=/; SameSite=Lax, no Max-Agenothing identity-relatedvb_visitor_session
Persistent — grantedvb_visitor with the same name and Max-Age=31536000vb_visitor_id mirrorvb_visitor_session
Decision record — runtime setConsent onlyvb_consent (granted / denied)

A grant arriving after boot upgrades in place: the same identifier value is rewritten with the persistent directive and mirrored, so the visitor is not re-identified as a different person by their own consent. A withdrawal erases vb_visitor_id, rewrites vb_visitor with the session directive, and does so immediately — no page reload, no WebSocket reconnect.

  • No persistent tier. The identifier never receives a Max-Age and never gets a mirror, no matter how long the visitor stays.
  • No identifier at boot. Nothing is written before engagement, and a socket opened before engagement presents no identifier — the server treats it as a guest conversation.
  • No cross-context recognition. The session carrier is per browsing context: without the persistent tier a new tab starts as a guest and mints a different identifier.
  • A persistent cookie is not read when its grant is not signalled on this page load. A stale Max-Age cookie from an earlier consent is neither read nor transmitted on a page that delivers no signal.
  • Unattributable conversations cannot be rated. A conversation the server holds without an identifier renders no rating control, because a rating must be attributable to the identifier.
FactValue
Cookie namevb_visitor
Domain / typefirst-party — written by the widget script via document.cookie on your site's origin; SameSite=Lax, plus Secure when the page is served over https:
Purposevisitor identifier for conversation continuity and rating attribution
Lifetime without consentsession only — no Max-Age; written only after the visitor engages
Lifetime with consentMax-Age=31536000 (365 days), same name, plus the vb_visitor_id localStorage mirror
Other browser storagevb_visitor_session in sessionStorage (per browsing context) and vb_consent in localStorage (decision record, written only by a runtime setConsent call)
Who collects consentyou — the widget renders no consent banner, no permission control and no consent UI of its own; wire your CMP to one of the three inputs

Trust model: the merchant is the controller

The consent signal is trusted from the embedding page by construction. The widget runs inside your document and cannot authenticate its caller: any script you admit to that page can set the attribute or call setConsent, and the widget does not detect, verify or record who called. You are the controller — you obtain consent, hold the record, and are accountable for the signal's truth. The widget's guarantee is narrower, and it is the whole of its duty: no persistent identity storage occurs while no signal is present, and a withdrawal is honoured immediately and completely on the visitor's device. The widget does not hold or validate a consent record.

Per origin, not per tenant

The signal's boundary is the browser origin, and this platform permits two tenants on one host. Every storage key this contract governs — vb_consent, vb_visitor, vb_visitor_id, vb_visitor_session — is scoped by the browser to the embedding page's origin and carries no tenant and no publishable-key dimension. Two tenants that each hold an active key for the same host share the keys: one tenant's data-consent="granted" selects the persistent tier for the identifier the other tenant's widget writes and reads, and both observe the same identifier value. The gate is per origin, not per tenant — a merchant sharing a host with another tenant should not assume an isolation the storage keys do not provide.

The server-side copy

The identifier is also stored server-side on the conversation record — conversations.metadata->>'visitor_id', written by open_conversation when the conversation opens. A setConsent(false) withdrawal erases the copies on the visitor's device and does not reach that server-side copy. This change sets no retention bound for that copy and adds no subject-access route for it — the registered owner is visitor-identifier-erasure, which is not yet an open lane.

This section is disclosure, not a mechanism: whether that copy is lawful to retain, and for how long, is your determination as controller. This change adds no server-side erasure, retention or subject-access mechanism, and no part of this page should be read as a legal conclusion.

Where to go next