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.
| Input | Where it lives | Value set | When it is read |
|---|---|---|---|
data-consent | attribute on the embed <script> tag | exactly granted / denied | at boot, by the widget loader |
CreateWidgetConfig.consent | consent option on the programmatic createWidget({ ... }) call | exactly granted / denied | at boot, when the programmatic entry resolves its config |
window.VoiceBot.setConsent(granted: boolean) | runtime global the bundle installs | exactly 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:
| Delivered | Resolution |
|---|---|
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 setConsent | malformed 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_consentinlocalStorage. - Neither boot input is ever written to that record — only a runtime
setConsentcall 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 asetConsent(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, neversessionStorage, so a withdrawal is at least as durable as the year-long storage it revokes: a second tab opened under a pinneddata-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.
| Tier | Cookie | localStorage | sessionStorage |
|---|---|---|---|
Session — no signal, malformed signal, or denied | vb_visitor with a session directive: Path=/; SameSite=Lax, no Max-Age | nothing identity-related | vb_visitor_session |
Persistent — granted | vb_visitor with the same name and Max-Age=31536000 | vb_visitor_id mirror | vb_visitor_session |
Decision record — runtime setConsent only | — | vb_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.
What stops working without consent
- No persistent tier. The identifier never receives a
Max-Ageand 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-Agecookie 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.
Cookie facts for your policy and CMP mapping
| Fact | Value |
|---|---|
| Cookie name | vb_visitor |
| Domain / type | first-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: |
| Purpose | visitor identifier for conversation continuity and rating attribution |
| Lifetime without consent | session only — no Max-Age; written only after the visitor engages |
| Lifetime with consent | Max-Age=31536000 (365 days), same name, plus the vb_visitor_id localStorage mirror |
| Other browser storage | vb_visitor_session in sessionStorage (per browsing context) and vb_consent in localStorage (decision record, written only by a runtime setConsent call) |
| Who collects consent | you — 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
- Quickstart — put the widget on your site.
- Vanilla installation — the attribute table, including
data-consent. - Privacy & store compliance — store-facing compliance guidance.