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.

Hostnames shown are the planned production endpoints. Your project’s Setup page in the dashboard shows the exact values for your project.

Install

Add the snippet to the <head> of every page. The core script has an enforced release budget of 25 KB gzip.

HTML, in <head>
<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 with sk_ and never belong in browser code.
Init options
OptionDescription
projectKeyRequired. Your public project key, starting with pk_. It is not a secret and is safe in page source.
endpointRequired. The regional ingestion endpoint shown on your project’s Setup page.
debugOptional. Logs the exact outgoing payloads to the console and keeps recent batches for cuebased('debug'). Defaults to false.

Test first, then verify your domain

Make a test visit, allow analytics and click around: your project’s Setup page shows the events stored for that visit. Test hosts such as localhost never count toward your allowance. Production capture starts once you have verified that you control the domain.

npm package

Publication pending during the pilot

The @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:
TypeScript, once the package is published
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 API

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”.

HTML
<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>
Annotation attributes
AttributeWhere it goes and what it does
data-cb-sectionOn a page region, for example pricing.plans. Measures section exposure and gives clicks inside the region context.
data-cb-actionOn a link or button, for example request-demo. Gives it a stable identity across page versions and redesigns.
data-cb-formOn a form. Names it; forms without a name are identified by their position on the page.
data-cb-goalOn a form, for example demo_request. Records each submit as an attempt at a registered goal.
data-cb-fieldOn a field. Names it for focus, blur and filled states. Its value is never read.
data-cb-privateOn any element. Nothing inside it is observed, and events that start there are discarded.
data-cb-visit-refOn a hidden input. Filled with the opaque visit reference when the form is submitted, only after consent.
data-cb-attempt-refOn a hidden input in a form with data-cb-goal. Filled with the goal attempt ID on submit, only after consent.

Key format

Keys are lowercase identifiers: letters, digits, _ 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.

  1. Register the goal key for your project, for example demo_request, with server confirmation.
  2. Mark the form with data-cb-goal, or record the attempt in code.
  3. Confirm the outcome from your server with a server event.
TypeScript, npm package
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.

POSThttps://eu.ingest.cuebased.com/v1/server-events

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

Server keys belong on your server. Never put them in browser code, a mobile app or a public repository.
Request body
{
  "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>"
    }
  ]
}
Server event fields
FieldDescription
eventIdRequired. Your stable ID for this outcome, such as a CRM record. It is the idempotency key. Up to 128 letters, digits, ., _, : or -.
goalRequired. A goal key registered for your project, such as demo_request.
statusRequired. confirmed, or failed when your server rejected the submission.
occurredAtRequired. When the outcome happened, as an ISO 8601 timestamp.
visitRefOptional. The value of cb_visit_ref. Links the outcome to the visit.
attemptIdOptional. 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 as duplicate, 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 visitRef and attemptId when the form fields are empty: the outcome is still recorded, just not linked to a visit.
curl
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"
    }]
  }'
Node.js (fetch), server only
// 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

200 OK
{
  "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):

TypeScript
// 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.

Debug mode
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/script and the afterInteractive strategy, as below, or use the npm package once it is published.
app/layout.tsx
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.