Headless
@blotout/qinto-sdk-browser is the Qinto client as an
npm package, for a site that ships its own JavaScript: a headless storefront, a single-page app, a framework app, a
TypeScript project. You call init once where your app starts and import track, identify and the other calls
anywhere. It has no runtime dependencies.
When to use it
Section titled “When to use it”Use the package when you build the site’s bundle and want the client inside it:
- A React, Vue, Svelte, Next.js, Nuxt or Remix app, or any single-page app with client-side routing.
- A headless storefront in front of Shopify, commercetools or a similar backend.
- A TypeScript project that wants typed calls and typed errors instead of
window.qinto. - A page that must decide when the client loads, for example after a consent platform has answered.
Pick another surface when:
- You can only paste a script. A CMS theme, a tag manager or a site you do not build gets the Browser tag, built from this same source.
- The event happens on a server. The package refuses to run outside a browser, because every reconnect there would mint a new visitor. A backend uses HTTP with the server SDK.
The package and the tag talk to the same edge, run the same apps and mint the same ids. Two things differ. You choose
when the client loads, and the page load is yours to record, because the package never sends PageView for you.
Install
Section titled “Install”npm i @blotout/qinto-sdk-browserThe package ships ES modules and its types, and it has no runtime dependencies to install next to it.
Initialize
Section titled “Initialize”Call init once, where your app starts. You do not have to await it, and you do not have to keep what it returns:
import { init } from '@blotout/qinto-sdk-browser'
init({ destination: 'https://q.example.com' })Then import the calls you need from any module:
import { identify, track } from '@blotout/qinto-sdk-browser'
await track('PageView')await identify({ email: 'ada@example.com' }, 'crm-user-8412')destination is your site’s Qinto host, the origin the tag would load /q.js from. The Settings → Capturing data
page shows it in the snippet: your own mapped hostname, such as https://q.example.com, or the site’s development
URL while DNS is pending. Use the development URL only to test. Its cookie belongs to a Qinto hostname, so a visitor
is not recognised between visits on Safari.
Options
Section titled “Options”| Option | Default | What it does |
|---|---|---|
destination |
Required | The origin of your Qinto host, with the scheme and no path. |
globalName |
'qinto' |
The window property this instance lives on. Change it when the page already has a window.qinto, or to connect to a second host. |
onError |
None | Receives a connection error that belongs to no single call, such as a dropped socket or an unreadable frame. A call’s own failure rejects that call’s promise instead. |
requestTimeoutMs |
10000 |
How long one call waits for its acknowledgement before it rejects with QintoTimeoutError. |
pageContext |
None | A function that names the real page when the client runs in a sandboxed frame. See Sandboxed pages. |
What init does
Section titled “What init does”init does two things, in order, and resolves to the client once both are done:
- It loads the site’s apps. It adds a script for
<destination>/q.js?tag=0, the same response the tag loads without the client code that this package already is. It carries the site’s configuration and the browser code of every installed app. The wait is capped at three seconds. A script still loading at that point no longer holdsinit, your calls go out, a console line says so, and the apps activate when the script lands. A script that fails to load leaves the site’s browser apps off for this page, and your events still go out. - It opens the connection. A WebSocket to
/openon your Qinto host. Your Qinto host sets the visitor cookie on the first connection, and after that the client reconnects on its own with a backoff of up to 30 seconds.
A call made before the connection is open waits on window[globalName] and is sent, in order, once it opens. A
component can track on mount without knowing whether init has finished. If the first connection fails, init
rejects and so does every call that was waiting.
Call init once per page. A second init on a name that is already running rejects with QintoError, so the apps
never run twice. Remove the tag snippet from a page when you adopt the package. The tag loads /q.js as an async
script and boots as soon as it arrives, and an init that runs after that, which is the normal case for an init in
an effect, rejects with the same QintoError. Only an init that runs before the tag has booted takes over the
snippet’s global and sends the calls it buffered.
Server-side rendering
Section titled “Server-side rendering”init runs in a browser only. On a server, in a Next.js server component or a Nuxt server route, it throws
QintoEnvironmentError, and so does every call. Put init in code that runs on the client only and once per page.
In an app with a client entry, such as a Vite main.tsx, call it there before you render. In a framework that renders
on the server, call it from an effect in a root client component, guarded so that it runs once. React Strict Mode runs
an effect twice in development, and the second init would reject.
'use client'
import { init } from '@blotout/qinto-sdk-browser'import { useEffect } from 'react'
let started = false
export const QintoProvider = () => { useEffect(() => { if (started) { return }
started = true init({ destination: 'https://q.example.com' }) }, [])
return null}Events that happen on your server, such as an order confirmation in a route handler, go through HTTP.
Page views in a single-page app
Section titled “Page views in a single-page app”The package records no page load, so a client-side navigation sends nothing until you call track('PageView'). Call
it from your router, once per route change:
import { track } from '@blotout/qinto-sdk-browser'
router.afterEach(() => { void track('PageView')})The Page load setting under Settings → Capturing data drives the tag only. The package ignores it, so a site that runs the tag on some pages and the package on others does not count a page twice.
A second host
Section titled “A second host”A page that sends to two Qinto hosts, for example a partner’s site and your own, starts each under its own name and addresses the second by that name:
import { client, init, track } from '@blotout/qinto-sdk-browser'
init({ destination: 'https://q.example.com' })init({ destination: 'https://q.partner.com', globalName: 'partner' })
await track('AddToCart', { value: 10.5 })await client('partner').track('AddToCart', { value: 10.5 })client(name) looks the instance up on window[name]. It creates nothing and holds only the name, so you can write it
at every call site. The module-level functions are client('qinto').
Sandboxed pages
Section titled “Sandboxed pages”In a sandboxed frame, such as a Shopify custom pixel, window.location and document.referrer name the frame, and the
site’s cookies do not reach the connection. Give init a pageContext function that returns the real page, and the
visitor the server reported on an earlier connect:
init({ destination: 'https://q.example.com', pageContext: () => ({ url: shopPage.url, referrer: shopPage.referrer, visitor: stored === undefined ? undefined : { id: stored.anonId, session: `${stored.session.id}.${stored.session.startedAt}` } })})The client calls it on every connect, reconnect and call, so it must return at once. The function returns an object with these properties:
| Property | Required | What it is |
|---|---|---|
url |
Yes | The absolute URL of the page the visitor sees. It names the page for the connection, the calls, the apps and query-parameter capture. If it is not an absolute URL, or the function throws, the client uses the frame’s own location and referrer instead. |
referrer |
No | The absolute URL of the page the visitor came from. Leave it off when there is none. |
visitor |
No | Continues the same visitor and visit on the next page. It goes to the connection only. Leave it off until the server has reported one in a qinto:connect event. |
visitor.id |
Yes, when visitor is set |
The anonId from an earlier qinto:connect event. |
visitor.session |
No | The event’s session.id and session.startedAt joined with a dot. Without it the visitor continues, but a new visit starts. |
Every call returns a promise that settles when your Qinto host accepted it.
| Export | What it does |
|---|---|
track(event, properties?, options?) |
Records something the visitor did. Resolves to { eventId }. See Actions. |
identify(traits?, id?) |
Merges traits into the visitor’s profile, and links id, an identifier of your own, to them. See User data. |
alias(id) |
Links an identifier of your own to the visitor without touching traits. |
consent(input) |
Saves the visitor’s consent choices. See Consent. |
getConsent() |
The consent state as the server last sent it. Sends nothing. |
getAnonId() |
The visitor’s anon_… id from the connection. Sends nothing. |
getVisitor() |
{ anonId, globalId, ip, geo }, asked from the server each time, so globalId reflects an identify that just linked the visitor. |
close() |
Stops reconnecting and rejects every call in flight. A page that lives for its whole session rarely needs it. |
client(name?) |
The same eight methods for the instance on window[name]. |
CONSENT_CATEGORIES |
The five category names, as a readonly array. |
The types QintoBrowserClient, QintoInitOptions, ConsentCategory, ConsentInput, ConsentState, InstallRule,
AppRule, Properties, Traits, TrackOptions and VisitorInfo are exported too.
The qinto:connect event
Section titled “The qinto:connect event”The client dispatches a qinto:connect event on window each time the socket connects, reconnects included:
window.addEventListener('qinto:connect', (event) => { const { name, anonId, session, reconnect } = (event as CustomEvent).detail})name is the global that connected. anonId is the visitor’s id. session is { id, isNew, startedAt }, the visit
the server put this connection in, with startedAt in epoch milliseconds. reconnect is false on the first connect
of this instance and true after. The event leaves out ip, geo and globalId, because every script on the page
can read it. Use getVisitor() for those.
Errors
Section titled “Errors”Every error the package throws extends QintoError, and all four classes are exported.
| Class | When |
|---|---|
QintoEnvironmentError |
init or a call ran outside a browser. |
QintoError |
window[globalName] belongs to something else on the page, or init ran twice on one name. |
QintoTimeoutError |
The call waited requestTimeoutMs with no acknowledgement. |
QintoProtocolError |
The edge refused the call, or the socket closed with the call in flight. Its message is the edge’s own. |
import { QintoTimeoutError, track } from '@blotout/qinto-sdk-browser'
try { await track('Purchase', { currency: 'USD', value: 20.5, orderId: '190315' })} catch (error) { if (error instanceof QintoTimeoutError) { // retry later: the order id keeps the retry from counting twice }}Content Security Policy
Section titled “Content Security Policy”A page with a CSP must allow your Qinto host in two directives. script-src needs https://q.example.com for
/q.js, and connect-src needs wss://q.example.com for /open. An https: source never matches a wss: URL, and a
blocked socket leaves init pending with every queued call.
Best practices
Section titled “Best practices”- Call
initonce, at the root of the app, in client-only code. Not in a component that mounts more than once, not in an unguarded effect, and not in a server component. A secondinitrejects. - Do not wait for
initbefore you track. Calls queue until the connection is open, so a component can track on mount.awaitindividual calls only where the order or the outcome matters. - Send
PageViewon every route change. The package records no page load. Calltrack('PageView')from the router, after the URL has changed, so the event carries the new page. awaitaPurchasebefore you navigate. The promise settles when the edge accepted the event, and a redirect before that can lose it. Send the order id with it, so a retry or a server-side copy counts once.- Send consent before the first event of the page. A visitor who has saved nothing has granted nothing, and with
consent mode on an event sent before the save reaches only
necessaryapps.await consent(...)before youtrack('PageView')when your banner already knows the choice. See Consent. - Link the visitor with
aliasor the second argument ofidentify. Anemailtrait is stored and links nothing. Send the identifier your backend will use, so a server-side event reaches the same person. - Handle
QintoTimeoutErrorwhere a lost event costs you. Queue the purchase and retry it. Elsewhere, let the rejection go. The client reconnects on its own. - Keep the token out of the bundle. The package needs none. If you find yourself adding an edge token to browser code, that call belongs on your server, see HTTP.
- Pass
onErrorto your error reporter. It is the only place a dropped socket or an unreadable frame is reported. A call’s own failure rejects that call’s promise instead. - Test the real host. Point
destinationat your mapped hostname before launch. The development URL works, but its cookie is third party and Safari forgets the visitor between visits.