Skip to content

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.

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.

Terminal window
npm i @blotout/qinto-sdk-browser

The package ships ES modules and its types, and it has no runtime dependencies to install next to it.

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.

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.

init does two things, in order, and resolves to the client once both are done:

  1. 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 holds init, 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.
  2. It opens the connection. A WebSocket to /open on 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.

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.

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 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').

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

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
}
}

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.

  • Call init once, 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 second init rejects.
  • Do not wait for init before you track. Calls queue until the connection is open, so a component can track on mount. await individual calls only where the order or the outcome matters.
  • Send PageView on every route change. The package records no page load. Call track('PageView') from the router, after the URL has changed, so the event carries the new page.
  • await a Purchase before 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 necessary apps. await consent(...) before you track('PageView') when your banner already knows the choice. See Consent.
  • Link the visitor with alias or the second argument of identify. An email trait is stored and links nothing. Send the identifier your backend will use, so a server-side event reaches the same person.
  • Handle QintoTimeoutError where 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 onError to 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 destination at your mapped hostname before launch. The development URL works, but its cookie is third party and Safari forgets the visitor between visits.