Documentation
Install and configure the Cuebased SDK
One script tag, one consent callback and a few optional annotations. This page covers everything from the first test visit to confirming outcomes from your server.
Install
Add the snippet to the <head> of every page. The core script has an enforced release budget of 25 KB gzip.
<script>
window.cuebased = window.cuebased || function () { (window.cuebased.q = window.cuebased.q || []).push(arguments); };
cuebased('init', { projectKey: 'pk_your_public_key', endpoint: 'https://eu.ingest.cuebased.com' });
</script>
<script async src="https://cdn.cuebased.com/sdk/v0/cuebased.js"></script>- The first script defines a small command queue. It observes nothing; it only holds your commands until the SDK has loaded.
- Loading the SDK is not permission to observe. Nothing runs until your consent manager grants analytics, as described under Consent.
pk_…is a public project key. Server keys start withsk_and never belong in browser code.
| Option | Description |
|---|---|
projectKey | Required. Your public project key, starting with pk_. It is not a secret and is safe in page source. |
endpoint | Required. The regional ingestion endpoint shown on your project’s Setup page. |
debug | Optional. Logs the exact outgoing payloads to the console and keeps recent batches for cuebased('debug'). Defaults to false. |
Test first, then verify your domain
npm package
Publication pending during the pilot
@cuebased/browser package is not published yet, so please don’t try to install it. Use the script tag for now. Once it is available, the same API looks like this:import { createCuebased } from '@cuebased/browser';
const cb = createCuebased({
projectKey: 'pk_your_public_key',
endpoint: 'https://eu.ingest.cuebased.com',
});
// consentManager stands in for your consent manager's API.
consentManager.onAnalyticsGranted(() => cb.setConsent({ analytics: true }));
consentManager.onAnalyticsRevoked(() => cb.setConsent({ analytics: false }));
cb.registerGoal({ key: 'demo_request', confirmation: 'server' });
const attemptId = cb.track('goal_attempt', { goalKey: 'demo_request' });
const visitRef = cb.getVisitReference(); // null without consent
cb.notifyRouteChange(); // for routers that bypass the History APIConsent
Cuebased is deny-by-default. Call the SDK from your consent manager whenever the visitor makes or changes a choice.
// The visitor allows analytics:
cuebased('consent', { analytics: true });
// The visitor refuses, or later withdraws consent:
cuebased('consent', { analytics: false });The SDK does not remember consent between page loads. Call cuebased('consent', …) on every page load, as soon as your consent manager knows the visitor’s stored choice. With the npm package, use cb.setConsent(…) in the same way.
| State | When and what runs |
|---|---|
pending | The state after init, on every page load. Nothing runs: no listeners, identifiers, storage or requests. |
granted | After { analytics: true }. Sanitized capture starts. |
denied | After { analytics: false } before any grant, or when a grant arrives while the browser sends a Global Privacy Control signal. Nothing starts. |
revoked | After { analytics: false } following a grant. Capture stops, and pending buffers and identifiers are cleared immediately. |
- Commands issued before consent, such as goal attempts, are dropped rather than queued for later.
- Global Privacy Control is respected by default: when the browser sends the signal, a grant is not applied.
- Continuing to browse is never treated as consent. See the consent model in the trust center.
Annotations
Annotations are optional. They give sections, actions, forms and fields stable names, so patterns survive redesigns and stories can say “pricing plans” instead of “the third block”.
<section data-cb-section="pricing.plans">
<a href="/demo" data-cb-action="request-demo">Request a demo</a>
</section>
<form data-cb-form="demo" data-cb-goal="demo_request" method="post" action="/api/demo">
<label>Work email <input type="email" name="email" data-cb-field="email" /></label>
<input type="hidden" name="cb_visit_ref" data-cb-visit-ref />
<input type="hidden" name="cb_attempt_ref" data-cb-attempt-ref />
<button type="submit">Request a demo</button>
</form>
<div data-cb-private>
<!-- Nothing inside this region is observed. -->
</div>| Attribute | Where it goes and what it does |
|---|---|
data-cb-section | On a page region, for example pricing.plans. Measures section exposure and gives clicks inside the region context. |
data-cb-action | On a link or button, for example request-demo. Gives it a stable identity across page versions and redesigns. |
data-cb-form | On a form. Names it; forms without a name are identified by their position on the page. |
data-cb-goal | On a form, for example demo_request. Records each submit as an attempt at a registered goal. |
data-cb-field | On a field. Names it for focus, blur and filled states. Its value is never read. |
data-cb-private | On any element. Nothing inside it is observed, and events that start there are discarded. |
data-cb-visit-ref | On a hidden input. Filled with the opaque visit reference when the form is submitted, only after consent. |
data-cb-attempt-ref | On a hidden input in a form with data-cb-goal. Filled with the goal attempt ID on submit, only after consent. |
Key format
_ and -, starting with a letter, in up to four dot-separated segments, for example pricing.primary_demo_cta. Values that look like personal data, such as email addresses or runs of five or more digits, are dropped and the element is treated as unnamed.On submit, and only after consent, the SDK fills cb_visit_ref with the opaque visit reference and cb_attempt_ref with the goal attempt ID. Your backend stores them with the submission and sends them back in a server event. Without consent, both fields stay empty.
Goals
A goal is the outcome you care about, such as a demo request. A browser submit is only ever an attempt; your server confirms what actually happened.
- Register the goal key for your project, for example
demo_request, with server confirmation. - Mark the form with
data-cb-goal, or record the attempt in code. - Confirm the outcome from your server with a server event.
cb.registerGoal({ key: 'demo_request', confirmation: 'server' });
// For flows without a form, record the attempt yourself:
const attemptId = cb.track('goal_attempt', { goalKey: 'demo_request' }); // null without consent
const visitRef = cb.getVisitReference(); // null without consent
// Send attemptId and visitRef to your backend with the request.Goal states in stories
Every story shows one of these states. A submit followed by a server error is never counted as a confirmed lead.
- Confirmed by server
- Your server confirmed the goal with a server event.
- Rejected by server
- Your server reported the attempt as failed, for example a rejected submission.
- Attempt, not confirmed
- The browser recorded an attempt, and no confirmation has arrived.
- No attempt
- The visit contains no attempt at the goal.
- Outcome unknown
- No server confirmation is set up for the goal, so the outcome is unknown.
Server events
Confirm registered goals from your backend. Only a server event can mark an outcome as confirmed.
Authenticate with a server key: Authorization: Bearer sk_…. You create it in the dashboard; it is shown once and stored only as a hash.
Server-side only
{
"events": [
{
"eventId": "crm:lead:8841",
"goal": "demo_request",
"status": "confirmed",
"occurredAt": "2026-09-26T10:15:00Z",
"visitRef": "<from cb_visit_ref>",
"attemptId": "<from cb_attempt_ref>"
}
]
}| Field | Description |
|---|---|
eventId | Required. Your stable ID for this outcome, such as a CRM record. It is the idempotency key. Up to 128 letters, digits, ., _, : or -. |
goal | Required. A goal key registered for your project, such as demo_request. |
status | Required. confirmed, or failed when your server rejected the submission. |
occurredAt | Required. When the outcome happened, as an ISO 8601 timestamp. |
visitRef | Optional. The value of cb_visit_ref. Links the outcome to the visit. |
attemptId | Optional. The value of cb_attempt_ref. Links the outcome to the browser attempt. |
- Idempotent by
eventId: sending the same event again is safe. It is recorded once and reported asduplicate, so retries never create a second conversion. - Until this confirmation arrives, a browser submit stays an attempt.
- Send identifiers only. Never include names, email addresses or other personal details; requests with unknown fields are rejected.
- Up to 100 events per request. Omit
visitRefandattemptIdwhen the form fields are empty: the outcome is still recorded, just not linked to a visit.
curl https://eu.ingest.cuebased.com/v1/server-events \
-X POST \
-H "Authorization: Bearer $CUEBASED_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [{
"eventId": "crm:lead:8841",
"goal": "demo_request",
"status": "confirmed",
"occurredAt": "2026-09-26T10:15:00Z",
"visitRef": "VALUE_OF_CB_VISIT_REF",
"attemptId": "VALUE_OF_CB_ATTEMPT_REF"
}]
}'// Server-side only, for example in the handler that stores a demo request.
// CUEBASED_SERVER_KEY holds your sk_… key. Never ship it to the browser.
export async function confirmDemoRequest(lead: {
id: string;
visitRef?: string; // from the cb_visit_ref form field
attemptRef?: string; // from the cb_attempt_ref form field
}) {
const response = await fetch('https://eu.ingest.cuebased.com/v1/server-events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CUEBASED_SERVER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
events: [
{
eventId: `crm:lead:${lead.id}`,
goal: 'demo_request',
status: 'confirmed',
occurredAt: new Date().toISOString(),
// Both fields stay empty without consent: omit them rather than send "".
...(lead.visitRef ? { visitRef: lead.visitRef } : {}),
...(lead.attemptRef ? { attemptId: lead.attemptRef } : {}),
},
],
}),
});
if (!response.ok) throw new Error(`Cuebased server event failed: ${response.status}`);
return response.json(); // { results: [{ eventId, status, linked }] }
}Response
{
"results": [
{ "eventId": "crm:lead:8841", "status": "accepted", "linked": true }
]
}Each event returns accepted, duplicate (already received, nothing changed) or rejected with a reason such as unknown_goal. linked tells you whether the event was matched to a visit.
Single-page apps
Most single-page apps need no extra code.
After consent, the SDK observes pushState, replaceState and back and forward navigation, while preserving your router’s behavior. Each route change starts a new page instance with a sanitized path. Routers that use the History API are detected automatically.
If your router swaps views without the History API, notify the SDK yourself (npm package):
// Only needed when your router swaps views without the History API.
// Call it after the new view has rendered:
cb.notifyRouteChange();Debugging
See exactly what leaves the browser, not a richer local preview.
cuebased('init', {
projectKey: 'pk_your_public_key',
endpoint: 'https://eu.ingest.cuebased.com',
debug: true, // logs every outgoing payload to the console
});
// Later, in the browser console:
cuebased('debug');
// → { consent: 'granted', halted: null, visitRef: '…', pageInstanceId: '…',
// configRevision: 3, outgoing: [ /* the exact batches that left the browser */ ] }- With
debug: true, the SDK logs the exact outgoing payloads to the console. cuebased('debug')returns a snapshot: the consent state, the visit reference, the page instance, the configuration revision and, in debug mode, the most recent outgoing batches.- Your project’s Setup page in the dashboard shows the events stored for your test visit, so you can compare both sides.
- Switch debug mode off before going live.
Privacy and limits
The SDK never reads page text. It sends element roles, approved data-cb-* keys and sanitized structure, nothing else.
- Private automatically: password fields, hidden inputs, payment fields marked with
autocomplete="cc-…"and one-time-code fields. - Private on request: anything inside
data-cb-private, and routes you exclude in your project’s privacy settings. - Path segments that look like identifiers, such as long numbers, UUIDs, tokens or email addresses, become
:id. Short IDs can look like ordinary words, so exclude routes that carry personal identifiers.
What the SDK cannot observe
- Cross-origin iframes, such as embedded booking, video or form widgets. A cooperating iframe needs its own permitted installation.
- Shadow DOM, canvas and embedded widgets can limit what the SDK can identify.
- Nested scroll containers are not measured in v1; scroll depth covers the page itself.
- Hover is only measured on pointing devices. Missing hover on a touch device is not read as disengagement.
- Blocked scripts, network loss and missing consent reduce coverage. Reports show captured visits and known gaps, never “all visitors”.
The full list of what is and is not sent is in the collection reference.
Compatibility
Cuebased works wherever you can add a script to every page. These are the setups documented today.
- Plain HTML
- Paste the snippet into the
<head>of every page, or into the shared template your pages use. - WordPress
- Add the snippet to the site-wide header with a code-snippets plugin, then call the consent command from your consent plugin’s callbacks.
- Webflow
- Paste the snippet into your project’s custom code, in the head code field, then publish the site.
- React and Next.js
- Load the snippet once in the root layout with
next/scriptand theafterInteractivestrategy, as below, or use the npm package once it is published.
import Script from 'next/script';
const cuebasedInit = `
window.cuebased = window.cuebased || function () { (window.cuebased.q = window.cuebased.q || []).push(arguments); };
cuebased('init', { projectKey: 'pk_your_public_key', endpoint: 'https://eu.ingest.cuebased.com' });
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script id="cuebased-init" strategy="afterInteractive">
{cuebasedInit}
</Script>
<Script src="https://cdn.cuebased.com/sdk/v0/cuebased.js" strategy="afterInteractive" />
</body>
</html>
);
}In every setup, wire consent the same way: call cuebased('consent', …) from your consent manager. With a strict Content Security Policy, allow https://cdn.cuebased.com in script-src and https://eu.ingest.cuebased.com in connect-src, and give the inline snippet a nonce or hash.