Recognise visitors with their consent
Audience can recognise a returning visitor on your site, so that what you know about them (their stage, the audiences they are in) follows them from one visit to the next. This page sets that up on a site with no forms: a script tag or a package that obtains a visitor token with the visitor’s consent, keeps it only while that consent stands, and lets the visitor forget their device from anywhere on your site.
If you also use ebitex Forms, the form embed and @ebitex/forms-sdk work with the same visitor. See
Add a form to your site.
What it does, and what it does not
Section titled “What it does, and what it does not”- It recognises a device, not a person. A visitor token says “this browser came back”. It becomes a person only when your site tells Audience who they are, for example when they sign in or submit a form.
- It stores nothing about browsing. Asking for a session records no page views and no events. If you want something a visitor did to appear on their timeline in Audience, you record it yourself, and only for a visitor Audience already recognises as a person.
- It keeps the token only with consent. When recognition is granted you get a long-lived
recognitiontoken, kept in a first-party cookie calledebitex_visitor. When it is not, you get asessiontoken that expires a few hours after it was issued whatever the visitor does, and that is never kept across visits. - Forgetting a device is not erasing a person. It ends the device’s token for good and ends what that device unlocked (the link to a person in Audience, a connected contact, an emailed-link verification). The person and anything they submitted stay; erasing them is a separate action by your organization.
Before you start
Section titled “Before you start”You need an Audience delivery key that is restricted to your site’s origins:
- In Audience, open Settings, then Delivery keys.
- Make a Browser-safe key and list the origins of your site, for example
https://www.example.com. - Copy the key. It is shown once.
The key is visible to anyone who views your page. That is safe only because the key works from the origins you listed and nowhere else. Never put an unrestricted key in a page. An unrestricted (server) key belongs on your server, as in the server-rendered recipe below.
Then decide what recognises a visitor by default. Open Visitor consent in Hub. Until an owner changes it, the organization’s default is not to recognise, so every page load is a new visitor. The help page for that screen explains each choice.
How consent is decided
Section titled “How consent is decided”ebitex decides; you send what you saw. The first answer that applies wins:
| Order | Signal | Where it comes from |
|---|---|---|
| 1 | The answer from your consent tool, granted or denied |
You pass it with setConsent |
| 2 | Global Privacy Control | The browser; the SDK reads it for you |
| 3 | Do Not Track | The browser; the SDK reads it for you |
| 4 | The organization’s default | Hub, Visitor consent |
A privacy signal from the browser that says no is honoured even when the organization’s default says yes.
The answer is in consent.recognition, and consent.source says which row decided it. The organization’s
own wording for a notice, a confirmation question and a result is in consent.messages, in the visitor’s
language.
Option 1: a script tag
Section titled “Option 1: a script tag”On a page with no build step, add the script once, on every page that should recognise visitors:
<script type="module" src="https://cdn.jsdelivr.net/npm/@ebitex/visitors-sdk@0/dist/script.js" data-ebitex-key="ebx_your_browser_key"></script>It asks for the visitor straight away and exposes the object as window.ebitexVisitors. To pass your consent
tool’s answer, call window.ebitexVisitors.setConsent({ cmp: 'granted' }) (or 'denied') from its callback.
If your consent tool answers before the visitor should first be recognised, add data-ebitex-start="manual"
to the script tag, and the script waits for your first call to refresh or setConsent.
| Attribute on the script tag | What it does |
|---|---|
data-ebitex-key |
Your browser-safe delivery key. Required. |
data-ebitex-endpoint |
The API address, if you were given another. Defaults to https://api.ebitex.io. |
data-ebitex-start="manual" |
Wait for your own call instead of asking at once. |
data-ebitex-embeds |
Let an ebitex form embedded in an <iframe> on this page recognise the same visitor. See Recognize the page’s visitor in an embedded form. |
data-ebitex-forms-origin |
The form host, if it is not https://forms.ebitex.io. |
A “forget this device” link
Section titled “A “forget this device” link”Mark any link or button, anywhere on your site, with data-ebitex-forget:
<footer><a href="#" data-ebitex-forget>Forget this device</a></footer>A click asks your organization’s own confirmation question, forgets the device, removes the cookie, and shows your organization’s own wording in a status line after the link. It works for controls added to the page later.
| Attribute on the control | What it does |
|---|---|
data-ebitex-forget-confirm="off" |
Skip the confirmation question. |
data-ebitex-forget-status="#id" |
Put the result in that element instead of a new status line. |
data-ebitex-done-text |
Wording to show if there was nothing to forget and ebitex had none to give. |
data-ebitex-error-text |
Wording to show if forgetting fails. The default is an English sentence. |
After a successful forget the script dispatches an ebitex:visitor-forgotten event on the document, with the
message in detail.
Option 2: the package
Section titled “Option 2: the package”npm install @ebitex/visitors-sdkCreate one Visitors object, outside any component, and ask for the visitor when the page loads:
import { createVisitors } from '@ebitex/visitors-sdk'
// A key restricted to your site's origins. It is visible to anyone who views the page, which is safe only because of that restriction.export const visitors = createVisitors({ key: 'ebx_your_browser_key' })
export async function onPageLoad(): Promise<void> { // Asks ebitex for the token to use now and whether this device may be recognised. Nothing about the visitor is stored by this call. const { consent } = await visitors.refresh()
if (consent.recognition === 'granted') { // The token is now kept in a first-party cookie called ebitex_visitor, and sent again on the next visit. console.log('Recognised for', consent.tokenLifetimeSeconds, 'seconds') } else { // Nothing is kept on the device. consent.source says why: your consent tool, the browser, or the organization's default. console.log(consent.messages.recognitionDeclined) }}Pass your consent tool’s answer on as it changes. A denied answer is a withdrawal: the token is removed from
memory and from the cookie straight away, before the network answers.
import { createVisitors } from '@ebitex/visitors-sdk'
const visitors = createVisitors({ key: 'ebx_your_browser_key' })
// Whatever your own consent tool calls its callback. Pass its answer on as it changes.export function onConsentChoice(analyticsAllowed: boolean, consentString?: string): Promise<unknown> { return visitors.setConsent({ cmp: analyticsAllowed ? 'granted' : 'denied', cmpString: consentString, // Global Privacy Control and Do Not Track are read from the browser for you. }) // 'denied' is a withdrawal: the token is removed from memory and from the cookie straight away, before the network answers.}By default the token is kept in a first-party cookie called ebitex_visitor, with SameSite=Lax, Secure
on https, and a lifetime no longer than the token’s. The cookie is readable by your own scripts, which is
why a token on its own unlocks nothing. To keep it somewhere else, pass storage with get, set and
remove. To keep nothing, pass storage: false, and every page load is a new visitor.
Forget this device
Section titled “Forget this device”import { createVisitors, VisitorsApiError } from '@ebitex/visitors-sdk'
const visitors = createVisitors({ key: 'ebx_your_browser_key' })
// Wire any link or button, for example one in your footer.export function wireForgetLink(link: HTMLElement, status: HTMLElement): void { link.addEventListener('click', async (event) => { event.preventDefault()
// The organization's own question, once the page has asked for the visitor's consent state. const question = visitors.consent?.messages.forgetConfirm if (question && !window.confirm(question)) return
try { // Ends the token for good, removes it from the cookie, and ends what the device unlocked. // It does not erase the person; that is a separate action by the organization. status.textContent = (await visitors.forget()) ?? '' } catch (error) { // The token is kept when the request fails, so the visitor can try again. status.textContent = error instanceof VisitorsApiError && error.status === 429 ? 'Too many tries. Please wait a moment.' : 'Something went wrong. Please try again.' } })}If the request fails, the token is kept and forget() rejects, so the visitor can try again.
@ebitex/visitors-sdk/react has a provider, a hook that re-renders when the token or the consent state
changes, and a button that asks the organization’s question and shows its wording:
import { useEffect } from 'react'import { createVisitors } from '@ebitex/visitors-sdk'import { ForgetDeviceButton, useVisitors, VisitorsProvider } from '@ebitex/visitors-sdk/react'
// Create it once, outside any component.const visitors = createVisitors({ key: 'ebx_your_browser_key' })
function PrivacyNotice() { const { consent, refresh } = useVisitors()
useEffect(() => { void refresh() }, [refresh])
if (!consent) return null return consent.recognition === 'granted' ? <p>We remember this device.</p> : <p>{consent.messages.recognitionDeclined}</p>}
export function Footer() { return ( <VisitorsProvider visitors={visitors}> <PrivacyNotice /> {/* Asks the organization's own question first, then shows its own "forgotten" wording beside the button. */} <ForgetDeviceButton>Forget this device</ForgetDeviceButton> </VisitorsProvider> )}Interactions and events
Section titled “Interactions and events”Audience keeps two different kinds of record about what a visitor does, and they are written by two different calls.
| Interaction | Event | |
|---|---|---|
| What it is | A meaningful fact about a person: they asked about pricing, they signed up | A high-volume signal: a page was viewed |
| Where it shows | On the person’s timeline, in exports and in reports | Nowhere as a row. Audiences count events (“viewed three pages about espresso this week”) |
| How long it is kept | By your retention policy | Raw events for about a month; the daily counts for a year |
| SDK call | interaction() |
event() |
| Request | POST /audience/delivery/v1/interaction |
POST /audience/delivery/v1/event |
An interaction never becomes an event, and an event never becomes an interaction. Page views are always events.
Record what a visitor did
Section titled “Record what a visitor did”interaction puts a named interaction on the timeline of the person this device is recognised as, for
example that they viewed your pricing:
import { createVisitors, VisitorsApiError } from '@ebitex/visitors-sdk'
const visitors = createVisitors({ key: 'ebx_your_browser_key' })
export async function onPricingViewed(): Promise<void> { try { // The name is lower-case words joined by hyphens. Properties may hold only visit properties your organization has declared // (Audience, Settings, Visit properties), typed as declared. The interaction id makes a retry safe. const result = await visitors.interaction('pricing-viewed', { buyerType: 'agency', seats: 12 }, { interactionId: 'pricing-2026-10-18' })
if (!result.recorded) { // Not an error. An anonymous visitor's interactions are never stored: result.reason says why // ('no_visitor' means the device is not yet linked to a person, 'session_token' that it is not being recognised across visits). console.log('Not recorded:', result.reason) } } catch (error) { if (error instanceof VisitorsApiError && error.code === 'undeclared_property') { // error.errors names each property that is not declared, for example { buyerType: 'This is not a declared visit property.' }. console.warn('Declare these properties in Audience first:', Object.keys(error.errors ?? {})) } else if (error instanceof VisitorsApiError && error.code === 'interaction_cap_reached') { // This device has recorded 200 interactions in the last 24 hours. } else { throw error } }}This is deliberately small:
- It is not a page-view tracker. An interaction is stored only when the device is recognised (a recognition
token that has not been forgotten, and consent granted on this device) and already linked to a person,
which happens when your site or a form identifies them. For an anonymous visitor nothing is stored; the
result says why, and that is not an error.
page-viewas a name is refused: use an event for that. - Properties must be declared. In Audience, open Settings, then Visit properties, and declare each property your site may send, with its type. An undeclared or mistyped property is refused and the error names it, so free-form personal data cannot reach the timeline.
- A name is lower-case words joined by hyphens, at most 59 characters. It is stored as the interaction kind
site.<name>. - Each device may record 200 interactions in any 24 hours. The next one is refused with
interaction_cap_reached. - A retry is safe if you send an
interactionId: the same id from the same device records nothing new. - Erasing the person, or the retention period you set, removes their interactions like any other part of their timeline.
Count what a visitor views
Section titled “Count what a visitor views”event records a behavioural event for the person this device is recognised as. The first kind is
content.viewed: a page in some categories was viewed. Audiences can then count views in a category, so you
can personalize for “readers of espresso content” as they browse.
import { createVisitors } from '@ebitex/visitors-sdk'
const visitors = createVisitors({ key: 'ebx_your_browser_key' })
/** * Record that the visitor viewed a page in some categories. `classification` is what the Content Delivery API returns for a published page: the ids of its categories, each with all of its * ancestors, so an audience that counts views in "Coffee" counts a view of an "Espresso" page. Nothing is stored for a visitor who is not recognised and linked to a person, and that is not an * error: the result says why. Recording never slows the page. */export async function onPageShown(classification: string[]): Promise<void> { const result = await visitors.pageViewed({ categories: classification }) if (!result.recorded) console.log('Not recorded:', result.reason)}
/** The same thing, spelled out. `event()` mirrors the `POST /audience/delivery/v1/event` route, and takes one event or an array of up to 20 at a time. */export async function onReadingFinished(classification: string[], seconds: number): Promise<void> { await visitors.event({ kind: 'content.viewed', subject: { categories: classification }, value: seconds })}The categories to send are the ones a published page already carries. Each page the Content Delivery API
returns has a classification: the ids of the categories it was published in, each with all of its ancestors.
Send them as they are, so an audience that counts views in “Coffee” also counts a view of an “Espresso” page.
On a page with the drop-in script, you can have the view recorded on load. Add data-ebitex-page-views to the
script tag and list the page’s categories in a meta tag, which a server-rendered site fills from
classification. A page without the tag records nothing, and the option is off unless you add it.
<meta name="ebitex:categories" content="7c0f2b1e-…,5d2a91c4-…"><script type="module" src=".../script.js" data-ebitex-key="ebx_your_browser_key" data-ebitex-page-views></script>A single-page app calls pageViewed({ categories }) on each route change instead.
What to know:
- Only a recognised, linked person’s events are kept. The same rule as an interaction: a recognition token that has not been forgotten, consent granted on this device, and a link to a person. For an anonymous visitor nothing is stored; the result says why, and that is not an error.
- Recording never slows your page. A
200means the event was queued. Under heavy load a queued event can be dropped, and the result saysevent_queue_full. - Send one event or up to 20.
event()takes one event or an array, and sends arrays in requests of at most 20. - A page names at most 16 categories, ancestors included. A category that is not an id, or a kind that is not
registered, is refused with a
400that names the member. - A retry is safe if you send an
eventId: the same id for the same category on the same day records nothing new. - Limits are answers, not errors. A feature that is switched off (
events_disabled), an organization at its monthly allowance (event_allowance_reached) and a device over its daily soft limit (event_cap_reached) all answer202with the reason. - Forgetting a device deletes the events it recorded. The person, the daily counts already made and the audience membership they earned stay until the person is erased. Erasing the person removes their events and counts.
- An audience counts views in a category over the last 1 to 365 days. Days are whole UTC days, so “the last 7 days” is the 7 days before today and today so far.
If you also use Forms
Section titled “If you also use Forms”A form rendered with @ebitex/forms-sdk can share the visitor this page already has, so a returning
visitor is recognised in the form as everywhere else, and forgetting the device forgets it everywhere. See
Share one visitor with the rest of your site.
Your visitor object has two methods for this, consentSignals() (what it sends with every request) and
adopt() (to take over a token that a form’s response issued), which you only call yourself if you are
connecting something other than a form.
A form shown in an <iframe> is a different case, because the frame cannot share this page’s cookie. connectEmbeds(visitors)
(or the data-ebitex-embeds attribute on the script tag) hands the page’s token to the frame, which keeps it in memory. See
Recognize the page’s visitor in an embedded form.
A site rendered on a server
Section titled “A site rendered on a server”For a link that works without JavaScript, post to a route on your own server. The route reads the cookie and calls ebitex with a server key, which never reaches the browser:
// A site that is rendered on a server, with a plain link or form button that works without JavaScript.// Your page posts to your own route; the route reads the visitor cookie your page set and calls ebitex.
/** Builds the route handler. `key` is an Audience delivery key, read from your server's own configuration. */export function createForgetRoute(key: string): (request: Request) => Promise<Response> { return async (request) => { const cookies = request.headers.get('cookie') ?? '' const token = /(?:^|;\s*)ebitex_visitor=([^;]+)/.exec(cookies)?.[1] const visitor = token ? decodeURIComponent(token) : null
// Always answers 200 with the organization's own wording, whether or not the token was valid. const answer = await fetch('https://api.ebitex.io/audience/delivery/v1/visitors/forget', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, body: JSON.stringify({ visitor }), }) if (!answer.ok) return new Response('Please try again.', { status: 502 })
// End the cookie too, and send the visitor back to the page they were on. return new Response(null, { status: 303, headers: { Location: '/privacy?forgotten=1', 'Set-Cookie': 'ebitex_visitor=; Path=/; Max-Age=0; SameSite=Lax' }, }) }}The forget request always answers 200 with your organization’s wording, whether the token was valid,
already forgotten or made up, so it cannot be used to find out which tokens are real. Calling it again is
harmless.
The raw requests
Section titled “The raw requests”All four calls are POSTs with Authorization: Bearer <delivery key> and a JSON body, and every answer is
private, no-store. See Start a visitor session,
Forget a device and
Record something a recognised visitor did and
Record a behavioural event for a recognised visitor in the API reference.
Say which campaign brought the visitor to an embedded form
Section titled “Say which campaign brought the visitor to an embedded form”connectEmbeds hands the form frame the tags of the link the visitor followed (utm_source, utm_medium, utm_campaign) together with the visitor’s token, so a submission can be attributed to its campaign. By default it reads the page’s own address,
so the drop-in script on a tagged page needs no configuration. The SDK stores nothing: pass attribution (a value, or a function asked each time a frame is answered) to give a first touch you kept from an earlier page, or () => undefined to send none.