Web SDK Integration
The Joryio Web SDK enables you to track user behavior, send events, and manage user profiles on your website.
Installation
Via npm/yarn
npm install @joryio/web-sdk
# or
yarn add @joryio/web-sdk
Via CDN
The SDK files are served from one global host, https://sdk.joryio.com,
whichever region your workspace is in. Events and API calls still go to your
region's API host (the apiEndpoint setting) - only the file location is global.
<!-- Recommended: the newest 1.x. Picks up fixes, never a breaking change. -->
<script src="https://sdk.joryio.com/web/1/joryio.min.js"></script>
<!-- Pinned: one exact version that never changes. You upgrade by changing the URL. -->
<script src="https://sdk.joryio.com/web/1.3.3/joryio.min.js"></script>
| Path | What it serves |
|---|---|
/web/1/<file> | The newest 1.x release (recommended) |
/web/<version>/<file> | One exact version, e.g. /web/1.3.3/ - its contents never change |
Files: joryio.min.js (the SDK), joryio-sw.js (the web push service worker)
and joryio.umd.js (the unminified UMD build).
/web/1/- most sites. Bug fixes and new 1.x features arrive automatically, and a breaking change (2.0) never arrives on this path.- Pinned (
/web/1.3.3/) - sites that want deterministic control over when the SDK upgrades. The file never changes, so browsers cache it for a long time.
Upgrading a pinned integration. When Joryio ships a new SDK version, bump the number in your snippet:
<!-- Before -->
<script src="https://sdk.joryio.com/web/1.3.3/joryio.min.js"></script>
<!-- After -->
<script src="https://sdk.joryio.com/web/1.4.0/joryio.min.js"></script>
The browser treats the new URL as a fresh file and runs the new SDK.
Until October 2026 the SDK file was served from the API host, at
https://api-eu1.joryio.com/sdk/web/.... That path no longer exists - replace
it with https://sdk.joryio.com/web/1/.
Async snippet
Paste this into your <head>. It loads the SDK without blocking the page, and calls made before the file has loaded are queued and replayed in order. Every public method can be called this way, and init itself may come later - for example from your consent banner's callback.
<script>
!function(){var e=window.joryio=window.joryio||[];if(!e.initialize)if(e.invoked)window.console&&console.error&&console.error("Joryio snippet included twice.");else{e.invoked=!0,e.methods=["init","track","identify","addAlias","setAttributes","setAttribute","incrementAttribute","unsetAttribute","addToArray","removeFromArray","reset","flush","optOut","optIn","updateConsent","getTrackingConsent","isUserOptedOut","setSdkAuthenticationToken","setSdkAuthenticationErrorCallback","getAnonymousId","getUserId","ready","getIdentity","getSessionInfo","getUTMData","updateUTM","syncInAppCampaigns","getInAppCampaigns","displayInAppCampaign","dismissInAppMessage","trackInAppConversion","ecommerce","wipeData","destroy"],e.keep=["init","optOut","optIn","updateConsent","wipeData"],e.factory=function(t){return function(){var n=Array.prototype.slice.call(arguments);if(n.unshift(t),e.length>=1e3)for(var i=0;i<e.length;i++)if(e.keep.indexOf(e[i][0])<0){e.splice(i,1);break}return e.push(n),/^(get|is)[A-Z]/.test(t)?void 0:e}};for(var t=0;t<e.methods.length;t++){var n=e.methods[t];e[n]=e.factory(n)}e.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://sdk.joryio.com/web/1/joryio.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n)},e.load()}}();
joryio.init({ sdkKey: 'jry_sdk_web_YOUR_KEY' });
</script>
- The queue holds at most 1,000 calls. Beyond that the oldest are dropped - never
init,optOut(),optIn(),updateConsent()orwipeData(). - A queued call cannot return a value. A getter such as
getUserId()orisUserOptedOut()returnsundefineduntil the SDK has loaded. To read an ID, or to useuser,pushor the trackerecommerce()returns, useready:joryio.ready(function (sdk) { sdk.getAnonymousId(); }). - A failed
initloses nothing. Ifinitfails (a missing or malformed SDK key - the reason is logged to the console),window.joryiostays the queue. Calljoryio.init({...})again with the right key and the SDK starts with every call made so far - privacy decisions first, then the rest in order, an unsubscribe included. - Calls made while tracking was off are dropped. A queued
track(), attribute write,addAlias()ortrackInAppConversion()made while the visitor was opted out or consent was denied - by the stored choice, the init config or a decision queued before it - is dropped, and a later queuedoptIn()orupdateConsent('granted')does not bring it back.identify()and unsubscribes are kept. The same goes for one queued before a queuedoptOut()orupdateConsent('denied'), or before awipeData()queued ahead ofinit. Aready()callback queued while tracking was off, or before a queuedoptOut()orupdateConsent('denied'), still runs, but thetrack()calls, attribute writes, aliases, conversions and subscribes it makes while it runs are dropped too - unless the callback itself lifts what turned tracking off, for the rest of it: its ownoptIn()lifts an opt-out, its ownupdateConsent('granted')lifts a denial, neither lifts the other, and nothing inside it lifts anoptOut()or denial queued after it. AwipeData()queued ahead ofinitdoes not gate aready()callback queued before it (the callback runs for the fresh visitor). Reads and unsubscribes work, and the SDK instance it was handed behaves normally afterwards. - Privacy decisions run first. A queued
optOut(),optIn()orupdateConsent(), and awipeData()queued beforeinit, are applied insideinit, before it sends anything - so no Session Start, in-app sync or push registration goes out under the state they replace:
<script>
/* the snippet above, then: */
joryio.updateConsent('denied'); // applied inside init, before anything is sent
joryio.init({ sdkKey: 'jry_sdk_web_YOUR_KEY' });
</script>
Quick Start
1. Initialize the SDK
First, initialize the SDK with your SDK key. You can find your SDK key in the Joryio dashboard under Settings → Apps.
import JoryioSDK from '@joryio/web-sdk';
// Initialize the SDK
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_your_sdk_key_here',
enableDebug: false, // Enable debug logging in development
});
The package has an exports map: an ES module build for import and a CommonJS build for require. Bundlers (webpack, esbuild, Rollup, Vite) use the ES module build for both, so an app that imports the SDK in one file and requires it in another still runs one SDK. The default import is the class in bundlers and in Node ESM (SSR, Vitest, .mjs scripts) alike, import { JoryioSDK } from '@joryio/web-sdk' works too, and TypeScript types it the same way with moduleResolution node16, nodenext or bundler.
When loading from the CDN instead of npm, the same class is available as window.Joryio:
const joryio = new Joryio({
sdkKey: 'jry_sdk_web_your_sdk_key_here',
});
2. Identify Users
Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled
Identify users when they sign up or log in:
// Identify a user
joryio.identify('user_123');
// Set profile attributes separately
joryio.setAttributes({
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
signupDate: '2024-01-15',
});
3. Track Events
Track user actions and behavior:
// Track a custom event
joryio.track('Product Viewed', {
product_id: 'prod_123',
product_name: 'Premium Plan',
price: 99.99,
currency: 'USD',
});
// Track page views
joryio.track('Page Viewed', {
page: '/pricing',
title: 'Pricing Page',
category: 'Marketing',
});
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
sdkKey | string | Required | Your SDK key from the Joryio dashboard |
apiEndpoint | string | https://api-eu1.joryio.com/api | Your region's API host. A bare host works; /api is added |
userId | string | - | Start with a known user ID |
anonymousId | string | generated | Use your own anonymous ID |
enableDebug | boolean | false | Enable debug logging in console (logLevel: 'debug'). An explicit logLevel wins |
batchFlushInterval | number | 5000 | How often to send batched events to server (milliseconds). Events are queued locally and sent every 5 seconds to reduce network requests. Values below 1000 are raised to 1000. |
batchSize | number | 50 | Events per request, and the queue size that triggers a send before the timer. Never more than 500 per request (the API's limit) |
sendImmediately | boolean | false | Send each event immediately without batching (not recommended for production) |
sessionTimeout | number | 1800000 | Session timeout in milliseconds (default: 30 minutes). A new session starts after this period of inactivity - checked on every track() and when the tab becomes visible again. |
trackSessionStart | boolean | true | Track one "Session Start" event per new session. A reload that resumes the session sends none. |
trackPageViews | boolean | false | Automatically track "Page Viewed" on page load |
captureUTM | boolean | true | Automatically capture UTM parameters from URL for campaign attribution |
resetSessionOnNewCampaign | boolean | false | Start a new session when UTM parameters change (useful for campaign-level session analytics) |
trackDeviceProperties | boolean | true | Include device info on Session Start |
persistQueue | boolean | true | Persist queued events to localStorage to survive page reloads |
maxQueueSize | number | 1000 | Most events the local queue holds while flushes fail; beyond it the oldest are dropped (with a warning in the log when logging is on) |
maxRetries | number | 3 | Immediate retries of one request (5xx, 429, network error). A batch that still fails is kept and retried later |
retryBackoffMs | number | 1000 | Initial retry backoff in milliseconds; doubles per failure, up to 5 minutes |
requestTimeout | number | 10000 | Timeout in milliseconds for each API request (events, attributes, in-app, push) |
trackPageProperties | boolean | true | Include the referrer and landing page in "Page Viewed" events |
respectDoNotTrack | boolean | true | Treat the browser's Do Not Track setting as an opt-out |
logLevel | string | - | 'error', 'warn', 'info' or 'debug'. Nothing is logged unless this or enableDebug is set |
trackingConsent | string | unset | 'granted', 'denied' or 'pending'. Leave it unset if your site has no consent banner. See Privacy and consent |
optOut | boolean | false | true opts the visitor out, remembered across page loads like optOut(). false does not lift a stored opt-out - only optIn() does |
enableSdkAuthentication | boolean | false | Attach and refresh a JWT your backend mints. See SDK authentication |
sdkAuthenticationToken | string | - | The first JWT, used when enableSdkAuthentication is true |
plugins | array | [] | Plugins with beforeTrack / afterTrack hooks (web only) |
inApp.enabled | boolean | true | Turn in-app messaging on or off |
inApp.evaluationMode | string | 'differential-sync' | When in-app campaigns are fetched: 'differential-sync' (on load, identify(), reset(), a new session, a watched attribute change and every inApp.syncInterval) or 'session-start-only' (on load, identify(), reset() and a new session only) |
inApp.syncInterval | number | 0 (off) | Re-fetch in-app campaigns every N milliseconds in 'differential-sync' mode. Values below 30000 are raised to 30000; paused while the tab is hidden |
inApp.allowHtmlJsInAppMessages | boolean | false | Allow HTML in-app messages. The message renders in a sandboxed iframe under a strict Content-Security-Policy: only the SDK's own bridge script runs - your own <script>, inline on* handlers and <iframe>s do not - so buttons and forms work through data-joryio-action / data-jry-form. Opt-in because it renders authored HTML. The message can never navigate its own frame: links and redirects go through the SDK, which opens them on your page (http(s) and root-relative links only); if the frame navigates anyway, the message is closed. Native messages display regardless. See In-App Messaging. |
inApp.displayOptions.defaultPosition | string | 'center' | 'top', 'center' or 'bottom': where a Custom-type message sits. Modal, banner, slide-up and full-screen messages keep their own placement |
inApp.displayOptions.defaultAnimation | string | 'fade' | 'fade', 'slide' or 'none': how messages enter. Skipped when the visitor's system asks for reduced motion |
inApp.displayOptions.backdropColor | string | 'rgba(0, 0, 0, 0.5)' | Colour of the dim layer behind modal and full-screen messages |
inApp.displayOptions.closeButtonColor | string | the message's text colour | Colour of the close button |
inApp.displayOptions.zIndex | number | 999999 | Stacking order of messages (the backdrop sits one below) |
inApp.onMessageDisplay / onMessageClick / onMessageDismiss | function | - | Callbacks for each step - see Callbacks |
Event Batching & Flushing
Events are batched locally and sent to the server in groups to optimize network usage:
- Automatic flush: Events sent every
batchFlushIntervalmilliseconds (default 5s) - Size-based flush: Sent immediately when
batchSizeevents accumulate (default 50) - Manual flush: Call
joryio.flush()to send queued events immediately - On page unload: queued events are handed to the browser with
fetch(..., { keepalive: true })- without cookies, with your SDK auth header. Only what fits the browser's 64 KB keepalive limit is handed off - one budget for the whole page, however many unload events fire; the rest stays queued for the next page view. While the browser is offline, or after the last send failed, nothing is handed off: the events stay queued rather than ride a request that is likely to fail - Failed sends never block the queue: each request carries at most
batchSizeevents (never more than 500, or about 900 KB). A request the API refuses as malformed (400, 413, 422) is split until the one bad event is dropped; network errors, 429 and 5xx keep every event and retry with exponential backoff; a 402 (event ingestion paused for your workspace), 403 or an invalid key stops sending events for the page view and keeps them. After a 402, profile writes wait too - except withdrawals, which the API still accepts and which are still sent - Timestamps: events carry an ISO 8601 UTC timestamp with milliseconds (
2026-10-07T09:30:00.123Z), as on every Joryio SDK success: falseis a failure: a 2xx response whose body sayssuccess: false- for a batch, identify, attributes, an alias, a subscription change or a push registration - is retried, never treated as done. For a batch, only the events the API lists infailedIndicesare kept- Refused for good means dropped: events the API reports in
rejectedIndicesare dropped, never retried.track()drops an event before it is queued (with a log line) when its name is not 1-255 characters or its properties exceed the API's limits: more than 50 KB serialized, more than 200 keys, or nested deeper than 5 levels - One user per request: a request never mixes events of two users, so a logged-out user's queued events cannot hold up the next user's (see SDK authentication)
- Profile writes are durable and ordered: attribute writes,
addAlias()and subscription and subscription-group changes are kept inlocalStorageuntil the API acknowledges them, so a navigation or a closed tab does not lose them, and at page unload the ones waiting are handed tofetch(..., { keepalive: true })as well. A newer change to the same channel, group or attribute replaces an older one still waiting, so an older change never lands after a newer one - One retry rule for every write (
identify()included): network errors, timeouts, 429, 5xx and 402 never count against it and are retried with a growing backoff; a 404 ("no profile yet") is retried at most 5 times and any other retryable refusal (success: false, an SDK-auth 401 for the current user) at most 10 times; 400, 403, 409, 413 and 422 drop the write with a log line - Queue cap: The local queue holds at most
maxQueueSizeevents (default 1,000); if the cap is exceeded (e.g., long offline periods), the oldest events are dropped (with a warning in the log when logging is on)
// Send events immediately instead of batching
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
sendImmediately: true // Send each event immediately
});
// Or configure batching behavior
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
batchFlushInterval: 10000, // Flush every 10 seconds
batchSize: 20 // Or when 20 events accumulate
});
// Or manually flush at any time
joryio.track('Important Event', {...});
joryio.flush(); // Send now
Session Management
Sessions track continuous user activity and are managed automatically:
- Session timeout: Default 30 minutes of inactivity (configurable via
sessionTimeout) - New session starts when:
- User first loads the page
- Session timeout period passes without any events
- User calls
joryio.reset()(e.g., logout) - New campaign is detected (if
resetSessionOnNewCampaignis enabled)
Configuring session timeout:
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
sessionTimeout: 3600000 // 1 hour in milliseconds
});
// Or shorter session timeout
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
sessionTimeout: 600000 // 10 minutes
});
Sessions are automatically managed based on user activity. Each event tracked resets the inactivity timer.
Session Start Tracking
By default, the SDK automatically tracks a "Session Start" event whenever a new session begins. This event:
- Can be used as a trigger in campaigns and journey flows
- Includes all session context (UTM parameters, referrer, landing page, and - when
trackDevicePropertiesis on - device data) - Is stored on the user's profile like any other event, so segments and analytics can count sessions per user
Example session start event:
// Automatically tracked when user visits your site
{
event: "Session Start",
properties: {
utm_source: "google", // If UTM parameters present
utm_medium: "cpc",
utm_campaign: "spring_sale",
referrer: "https://google.com",
landing_page: "https://example.com/..."
},
userId: "user_123", // If identified
anonymousId: "anon_456",
sessionId: "sess_789"
}
Automatic Session Data
The Web SDK enriches Session Start events with device and environment data:
$user_agent$timezone$screen_width/$screen_height$viewport_width/$viewport_height$language/$languages$platform$browser$device_idcountry(ISO-3166-1 alpha-2, derived from IP at session start)
Use cases:
-
Welcome journeys: Use
Session Startas a journey trigger to reach users when they arrive on your site. -
Session-based segments: Session Start events are stored per user, so event-based segment conditions can count them - for example, "performed
Session Startat least 10 times" (power users) or "has not performedSession Startin the last 7 days" (re-engagement). -
Campaign attribution: Filter analytics on
Session Startand group byutm_campaignto see which campaigns drive the most sessions.
Disabling session start tracking:
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
trackSessionStart: false // Disable automatic session start events
});
In-App Messaging
The SDK displays in-app messages for you. Eligible campaigns appear on their own and impressions, clicks and dismissals are tracked automatically.
Delivery tokens
When the backend serves an eligible campaign it also issues a short-lived signed delivery token for it. The SDK echoes that token back when it reports an impression, click or dismissal, and the server verifies the signature before recording anything.
You do not have to do anything - the SDK handles this for you. It is
documented because the server can be configured to require it. By default a
report that carries no token is still accepted - the server logs it as a
pre-token client - and only when the backend runs with
IN_APP_REQUIRE_DELIVERY_TOKEN=true is that same report rejected:
POST /v1/in-app/track (no deliveryToken)
{ "success": false, "error": "A delivery token is required" }
A token that is present but does not verify - forged, tampered with or past its lifetime - is rejected whatever the setting.
The token is what makes an impression trustworthy: without it, anyone holding the SDK key - which ships inside every app and page - could report impressions and clicks for a campaign that was never shown, and your reporting would count them.
If in-app impressions stop being recorded on a backend that enforces tokens,
check that the page is running a current build of the SDK rather than a copy
vendored earlier: a build made before delivery tokens existed sends no token. Sites
loading the hosted bundle from https://sdk.joryio.com/web/1/joryio.min.js pick this up
automatically.
Two kinds of message content
A campaign arrives as one of two content shapes, and the SDK renders each one differently:
| Content | What it is | How it renders |
|---|---|---|
| Native | Structured data - headline, body, image, buttons | Plain DOM elements, inserted as text nodes. No iframe, no script execution. |
| HTML | Author-supplied markup and CSS, kept as written (only <base> and <meta http-equiv="refresh"> are removed) | A sandboxed iframe on your page, under a fresh Content-Security-Policy per message where only the SDK's own bridge script runs. |
Allowing HTML messages
HTML messages are turned off by default. Your own scripts do not run in them: Joryio keeps the markup as written, but each message gets a fresh Content-Security-Policy with a new nonce, so only the SDK's bridge script runs - an author's <script> blocks and on* handlers stay inert, and the frame can make no network calls (connect-src 'none') and post no forms (form-action 'none'). But that frame can execute script at your page's origin, so the remaining risk is a gap in that policy, such as a browser that does not enforce it - which is why turning it on is a decision your own team makes, not something switched on from a marketing dashboard:
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_YOUR_KEY',
inApp: {
allowHtmlJsInAppMessages: true, // default: false
},
});
Leaving it off does not disable in-app messaging. Native messages still display, because they are data written into DOM text nodes with no interpreter involved. HTML campaigns are skipped (with a warning in the log when logging is on), so a site that has not opted in shows no message rather than a broken one. A skipped campaign uses no impression and stays eligible.
If your Content Security Policy forbids inline scripts or framed content, leave this off and author your campaigns as native messages.
Styling native messages from your own CSS
A native message is real DOM in your page, not an iframe, so you can style it like anything else you own. The renderer emits stable hooks:
.joryio-inapp-native /* the card */
.joryio-inapp-native h2 /* headline */
.joryio-inapp-native p /* body */
.joryio-inapp-native img /* image */
.joryio-inapp-native button.primary /* first button */
.joryio-inapp-native button.secondary /* the rest */
.joryio-inapp-close /* close affordance */
.joryio-inapp-backdrop /* dim layer */
.joryio-inapp-modal / -banner / -slideup / -fullscreen /* per type */
Prefer the variables to the selectors. Everything a campaign can set is read
from a custom property, so setting one on :root gives you a house style that
campaigns can still override for a specific message:
:root {
--joryio-inapp-bg: #0A1240;
--joryio-inapp-fg: #FFFFFF;
--joryio-inapp-primary: #00C8B7;
--joryio-inapp-primary-fg: #041028;
--joryio-inapp-radius: 18px;
--joryio-inapp-font: 'Inter', system-ui, sans-serif;
--joryio-inapp-size: 15px;
--joryio-inapp-align: start; /* start | center | end */
--joryio-inapp-title-weight: 700;
}
The precedence, highest first:
- what the campaign sets in Style (optional) - written inline on the card
- your
--joryio-inapp-*values - the SDK's own defaults, which are system colours
So a campaign that sets nothing inherits your house style, and one that sets a
background wins for that message only. No !important anywhere.
If you do reach for the class selectors instead, note that the SDK's stylesheet
is injected at display time and therefore lands after yours in document
order, so it wins ties. Add specificity - .joryio-inapp .joryio-inapp-native {…}
- rather than a bare single-class rule.
Direction is handled for you: the card carries dir="auto", so a Hebrew or
Arabic message aligns right and puts its primary button on the right, inside an
otherwise left-to-right page.
Message Types
- Modal - Center of screen with backdrop
- Banner - Top of the page
- Slide-Up - Small notification from the bottom
- Full-Screen - Takeover message
- Custom - Your page decides placement
Display options
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_YOUR_KEY',
inApp: {
displayOptions: {
defaultPosition: 'bottom', // Custom-type messages only
defaultAnimation: 'slide', // 'fade' (default), 'slide' or 'none'
backdropColor: 'rgba(10, 18, 64, 0.6)',
closeButtonColor: '#ffffff',
zIndex: 2147483000,
},
},
});
Callbacks
Each callback receives the message; its campaign is message.campaign (message.campaign.id, message.campaign.name).
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_YOUR_KEY',
inApp: {
onMessageDisplay: (message) => console.log('shown', message.campaign.id),
onMessageClick: (message, action) => console.log('clicked', message.campaign.id, action), // action: the button id
onMessageDismiss: (message, action) => console.log('dismissed', message.campaign.id, action), // e.g. 'close_button'
},
});
The callbacks are notifications: the SDK still draws the message.
A callback that throws is logged and ignored: the SDK still records the impression, the click or the dismissal, and cleans up.
When two triggers fire together (the same event twice, say), the message is shown - and counted - once.
Manual control
await joryio.syncInAppCampaigns(); // fetch campaigns now
const campaigns = joryio.getInAppCampaigns(); // what is currently eligible
await joryio.displayInAppCampaign(campaignId); // show one now
joryio.dismissInAppMessage(campaignId); // close it
await joryio.trackInAppConversion(campaignId, eventName); // record a conversion
| Method | Description |
|---|---|
syncInAppCampaigns(attributeChanges?) | Ask the server for this visitor's campaigns now. Does nothing while tracking is off or consent is pending |
getInAppCampaigns() | The campaigns currently eligible, as synced |
displayInAppCampaign(campaignId) | Show one of those campaigns now (its frequency cap still applies) |
dismissInAppMessage(campaignId) | Close a displayed message (recorded as dismissed) |
trackInAppConversion(campaignId, eventName) | Record a conversion for a campaign |
When a message counts as displayed
A message counts as displayed - in_app.displayed is reported and the campaign's frequency cap is spent - only once the person could have seen it. The time it must be on screen is IN_APP_DISPLAY_DWELL_MS (1000 ms), the same constant on every Joryio SDK.
- A message appears only once the page has finished loading and gone idle, and never while the page is unloading (a redirect, a link being followed, the tab closing).
- It counts as displayed after it has been on screen for 1 second with the tab visible. If the tab is hidden first, the second starts again when it is visible.
- If the page unloads before then - a sign-in redirect, a logout link - nothing is reported, the cap is untouched, and the message is shown again on the next page.
- A click or a close before then counts as seen:
displayedis reported first, then the click or dismissal. Removing the message from code (dismissInAppMessage,reset()) before then reports nothing. onDisplayfires when the message counts as displayed, together with the report - not when it is put on screen.IN_APP_DISPLAY_DWELL_MSis exported by the package.
Privacy and consent
Two independent switches decide whether anything is collected: consent (your cookie banner's answer) and the visitor's own opt-out. The SDK collects only when consent is granted and the visitor has not opted out - any "no" wins.
joryio.updateConsent('granted'); // or 'denied' or 'pending'
joryio.optOut(); // remembered across page loads until optIn()
joryio.optIn();
joryio.ready(function (sdk) {
sdk.getTrackingConsent(); // 'granted' | 'denied' | 'pending'
sdk.isUserOptedOut(); // the opt-out switch only
});
| State | What the SDK does |
|---|---|
Consent granted (or never set, with no stored denial) | Collects and sends normally |
Consent pending | Collects locally and sends nothing - no Session Start, no in-app sync, no push registration - until updateConsent('granted') releases it. updateConsent('denied') discards it. What is held is kept in localStorage, so a reload does not lose it: events and identify() (up to maxQueueSize events), attribute writes (up to 500) and alias and subscription changes (up to 200). Unsubscribes are the exception: they are sent anyway. The web push service worker does not report deliveries or clicks meanwhile |
Consent denied | Collects nothing and clears anything queued. The service worker stops reporting deliveries and clicks |
optOut() | Collects nothing, clears queued events and pending attribute, alias and subscribe writes (unsubscribes are still delivered), and is stored so it survives a reload. The web push service worker still shows notifications but stops reporting deliveries and clicks |
| Do Not Track | Treated as an opt-out while respectDoNotTrack is true (the default) |
How the two switches behave:
deniedis remembered. It stays in force on later page views untilupdateConsent('granted')or an explicittrackingConsent: 'granted'in the init config. LeavingtrackingConsentunset keeps a stored denial - which is why the option has no default.pendingis for one page view. It is never stored, and never overrides a stored denial.- Consent never lifts an opt-out.
updateConsent('granted')does not undooptOut(); onlyoptIn()does.optOut()andoptIn()never change the stored consent. - The profile records the opt-out. When the opt-out switch changes - including
optOut: trueat init - the SDK writestracking_opted_out(trueorfalse) to the profile, with the IDs of the visitor who made the choice, retried by the retry rule. Nothing is sent while the switch is unchanged.trueis sent even while consent is denied or pending;falsewaits until tracking is allowed. - Withdrawals are always delivered. An unsubscribe (
SubscriptionStatus.UNSUBSCRIBED,removeFromSubscriptionGroup()), thetracking_opted_outmarker and a push unregister are sent even while the visitor is opted out, with consent denied or pending, and afterwipeData(). Every other write stops with them. - A session that began while tracking was off gets its one Session Start when consent is granted or the visitor opts back in.
identify()while tracking is off (consent denied, or opted out) sends nothing and writes nothing to a profile. The user ID is kept on this device only, so a later unsubscribe or opt-out reaches that person rather than an anonymous profile; the normalidentify()runs when tracking is allowed again.reset()andwipeData()forget it. On a grant or opt-in the keptidentify()runs before the Session Start of a session that began meanwhile, so that session is the new user's. The kept ID is used for every withdrawal - an unsubscribe, a group unsubscribe, thetracking_opted_outmarker andpush.unsubscribe(). Anidentify()once tracking is allowed replaces it, and an explicituserIdin the init config wins over it.- Every open tab follows. An opt-out, a consent change, a
reset()or anidentify()made in one tab of your site applies to its other open tabs at once, not at their next reload. A logout in one tab is a fullreset()in the others: they move to the new visitor and the new session, and writes they made for the outgoing user stay that user's. A grant in one tab also releases a tab waiting inpending. Another tab or script callinglocalStorage.clear()never lifts an opt-out or a denial - the tab keeps it and stores it again. The profile writes and logged-out users' tokens that every tab keeps inlocalStorageare merged, never overwritten by one tab. The user anidentify()keeps for withdrawals while tracking is off is followed too. An explicitupdateConsent('granted')oroptIn()made right after such alocalStorage.clear()is newer than the stored decision and wins in every tab. A profile write that another tab has already delivered, or replaced with a newer one, is never sent by this tab, andwipeData()in one tab discards the other tabs' waiting writes too (withdrawals excepted). - With the async snippet, these calls take effect before
initsends anything - see Async snippet.
Erasing local data
await joryio.wipeData();
Erases everything the SDK keeps about the visitor in this browser: the identity (a new anonymous ID, no user ID), queued events and a pending identify(), pending attribute, alias and subscription writes, in-app state and impression history, the session, UTM data, the device ID and the push subscription (unregistered with Joryio as well; it stays unsubscribed until push.subscribe() is called again). Withdrawals still waiting - an unsubscribe, the opt-out marker, the push unregister - are still delivered, with the IDs they were made with. It keeps the opt-out and a denied consent, and does not opt anyone out - that is optOut(). Tracking continues as a new anonymous visitor, in a new session with its own Session Start.
API Reference
Initialize
const joryio = new JoryioSDK(config)
Initialize the SDK with your configuration. The constructor returns a singleton - constructing it a second time returns the existing instance, which JoryioSDK.getInstance() also returns (or null before init).
The SDK never throws into your page. A missing or malformed sdkKey is logged once, whatever the log level, and that instance ignores every call; a null argument where one is required is logged and ignored.
Identify
joryio.identify(userId)
Associate a user ID with the current session.
Parameters:
userId(string): Unique identifier for the user
Example:
joryio.identify('user_123');
joryio.setAttributes({
name: 'John Doe',
plan: 'premium',
});
Track
joryio.track(eventName, properties?)
Track a custom event with optional properties.
Parameters:
eventName(string): Name of the eventproperties(object, optional): Event properties
Example:
joryio.track('Plan Upgraded', {
from_plan: 'basic',
to_plan: 'premium',
});
For purchases use the e-commerce helper, which sends the keys revenue reporting reads (orderId and total on an Order Completed event):
joryio.ecommerce({ currency: 'USD' }).purchase({
orderId: 'order_789',
items: [{ productId: 'SKU123', name: 'Blue Shirt', price: 49.99, quantity: 3 }],
value: 149.99, // sent as `total`
});
See E-Commerce Tracking for every helper method. Every helper checks its required arguments - a productId, an orderId, a finite order value, an items array - and when one is missing it logs a warning and sends nothing (never an empty or zero-value order). It never throws into your page. ecommerce() takes one option, currency (default 'USD').
Enable automatic page view tracking:
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
trackPageViews: true
});
Add Alias
joryio.addAlias(aliasLabel, aliasName)
Add a labelled alias to this visitor. It works before identify() too: the request carries the anonymous ID (and the user ID once identified), and the alias is attached to whichever profile those resolve to.
To link an anonymous visitor to their user ID, call identify(). On a first sign-in the anonymous profile becomes the user's profile, history and all; if the user already has a profile, the anonymous one is merged into it. The unlabelled alias() was removed in 1.3.0.
Example:
joryio.addAlias('crm', 'crm_98765');
Reset
joryio.reset()
Log the current user out. The visitor gets a new anonymous ID and a new session; the previous user's in-app messages are cleared and their SDK authentication token is forgotten; their attribute, alias and subscription writes not acknowledged yet are still sent, with their own IDs - never the next visitor's; UTM data (first and last touch included) is cleared; in-app campaigns are fetched for the new visitor; and an existing web push subscription is registered again under the new anonymous ID, so the previous user's pushes stop arriving on this browser.
The new identity is in place as soon as reset() returns. The promise it returns only covers delivering what was already queued (each queued event keeps the IDs it was tracked with), so reset(); identify(nextUser) is safe without await.
Example:
// On user logout
function handleLogout() {
joryio.reset();
// ... other logout logic
}
Get Anonymous ID
joryio.getAnonymousId()
Return the current anonymous ID - the id Joryio assigns to every visitor before they are identified. It is generated on first initialization, persisted in localStorage, and stable across page loads for the life of the anonymous visitor (reset() regenerates it on logout). This is the exact anonymousId attached to every event track() sends, so use it to stitch a server-side event or a consent-log entry to the same profile.
Returns:
string- the anonymous ID (always present)
With the async snippet, window.joryio is a command queue until the SDK finishes loading, and a queued call cannot return a value. Read the id on the resolved instance after load, or from inside ready() (below).
Example:
joryio.ready(function (sdk) {
const anonId = sdk.getAnonymousId();
// attach it to your own consent log / server event
fetch('/consent', { method: 'POST', body: JSON.stringify({ anonymousId: anonId }) });
});
Get User ID
joryio.getUserId()
Return the current identified user ID, or null if the visitor is still anonymous (i.e. identify() has not been called).
Returns:
string | null
Example:
joryio.ready(function (sdk) {
const userId = sdk.getUserId(); // null until you call joryio.identify(...)
});
Ready
joryio.ready(callback)
Run callback(sdk) once the SDK is loaded and initialized. This is the safe way to read a value (such as getAnonymousId() / getUserId()) that a queued snippet stub cannot return before load. callback receives the SDK instance; if the SDK is already loaded, it runs immediately.
Example:
joryio.ready(function (sdk) {
console.log('anon:', sdk.getAnonymousId(), 'user:', sdk.getUserId());
});
Get UTM Data
joryio.getUTMData()
Get current, first touch, and last touch UTM parameters.
Returns:
- Object with
current,firstTouch, andlastTouchUTM data
Example:
const utmData = joryio.getUTMData();
console.log(utmData.current?.utm_source); // "google"
console.log(utmData.firstTouch?.utm_campaign); // "awareness_campaign"
console.log(utmData.lastTouch?.utm_campaign); // "conversion_campaign"
Update UTM
joryio.updateUTM()
Manually update UTM parameters from the current URL. Useful for Single Page Applications that change URLs without page reload.
Example:
// React Router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
useEffect(() => {
joryio.updateUTM();
}, [location]);
}
// Vue Router
router.afterEach(() => {
joryio.updateUTM();
});
Attributes
joryio.setAttributes(attributes)
joryio.setAttribute(key, value)
joryio.incrementAttribute(key, by?)
joryio.unsetAttribute(key)
setAttributes({ plan: 'pro', city: 'Athens' })sets several at once;setAttribute('plan', 'pro')sets one.- A
nullorundefinedvalue removes the attribute, the same asunsetAttribute(key). - A value JSON cannot carry -
NaN,Infinity, aBigInt, a function, an object that refers to itself - is dropped with a warning in the log: that key is left out and the rest of the call goes ahead. The same goes for event properties. One bad value never holds up other writes. incrementAttribute('logins', 1)adds to a number on the server (default1), so two tabs never overwrite each other's count.- Writes made close together are sent as one request. They wait for a pending
identify()to reach the server first, are kept inlocalStorageuntil acknowledged, and a failed write is retried by the retry rule. - A write keeps the identity it was made under: one made just before
identify()is sent with that user ID once the identify has reached the server; one of user A still waiting whenidentify('B')orreset()is called still goes to A.
Array Attributes
joryio.addToArray(key, value)
joryio.removeFromArray(key, value)
Manipulate array attributes efficiently.
Parameters:
key(string): Attribute namevalue(any): Value to add or remove
Example:
// Add tags to user
joryio.addToArray('tags', 'vip');
joryio.addToArray('tags', 'premium');
// Result: tags = ['vip', 'premium']
// Add duplicate (no-op, prevents duplicates)
joryio.addToArray('tags', 'vip');
// Result: tags = ['vip', 'premium'] (unchanged)
// Remove tag
joryio.removeFromArray('tags', 'vip');
// Result: tags = ['premium']
// Common use cases
joryio.addToArray('interests', 'technology');
joryio.addToArray('purchasedProducts', 'prod_123');
joryio.addToArray('featureFlags', 'beta-access');
Behavior:
addToArray()only adds value if it doesn't already exist (prevents duplicates)addToArray()creates a new array if the attribute doesn't existremoveFromArray()removes all instances of the value- Changes are synced to the backend automatically, for both identified and anonymous users
Calls on one attribute keep their order: two addToArray() calls on the same key both reach the server, and an addToArray() followed by a removeFromArray() of the same key are sent one after the other, never in one request.
Flush
await joryio.flush()
Send what is queued now instead of waiting for the next batch. It also waits for a send already in progress.
flush() skips the SDK's own retry backoff, but never a Retry-After the API asked for.
Identity and session info
joryio.getIdentity() // { userId, anonymousId, attributes }
joryio.getSessionInfo() // { sessionId, sessionStart, lastActivity, isNewSession }
attributes holds what this page set (it is not read back from the server).
Consent and opt-out state
joryio.getTrackingConsent() // 'granted' | 'denied' | 'pending'
joryio.isUserOptedOut() // true after optOut(), until optIn()
isUserOptedOut() reflects only the visitor's own opt-out - not consent and not Do Not Track. See Privacy and consent. With the async snippet, read them inside ready().
Destroy
await joryio.destroy()
Send what is queued, stop every timer, remove the page listeners and unload plugins. A later new JoryioSDK(config) starts a fresh instance.
Web push
Web push needs a service worker on your own origin (browsers require it). Host a file named joryio-sw.js at the root of your site containing one line:
importScripts('https://sdk.joryio.com/web/1/joryio-sw.js');
Then ask for permission from a user gesture, such as a button click:
if (joryio.push.isSupported() && joryio.push.getPermissionState() === 'notDetermined') {
button.onclick = () => joryio.push.subscribe();
}
| Method | Description |
|---|---|
push.isSupported() | Whether this browser can do web push at all (needs HTTPS, a service worker and the Push API) |
push.getPermissionState() | 'granted', 'denied', 'notDetermined' or 'unavailable' - the same names as every Joryio SDK |
push.prepare(options?) | Register the service worker and fetch the key ahead of time, so the later subscribe() prompts at once. Never prompts |
push.subscribe(options?) | Prompt (if needed), subscribe, and register this browser with Joryio. Resolves the subscription, or null |
push.getExistingSubscription(options?) | The current subscription, without prompting |
push.unsubscribe(options?) | Remove this browser's subscription and unregister it with Joryio. It stays unsubscribed on later visits until subscribe() is called again. Never rejects; resolves false when nothing could be removed |
options takes swPath (default '/joryio-sw.js') and scope (default '/') if you host the worker elsewhere.
You do not need to call subscribe() again on later visits. When permission is already granted, the SDK checks the browser's subscription on load and registers it again if the browser rotated it, if your workspace's key changed (it re-subscribes without a prompt), or after reset() - unless unsubscribe() was called. Each registration carries the SDK's stable device ID. Nothing is registered while tracking is opted out or consent is pending. The unregister of unsubscribe() is a withdrawal: it is sent even then, kept until the API answers, and retried by the retry rule. Registering the same browser again cancels an unregister not sent yet.
The service worker shows each push with its title, body, icon, image, badge, actions, tag (a newer push with the same tag replaces the one on screen) and requireInteraction (stays until dismissed), reports its delivery and records the click. It reads these keys from the push's data, the same names the Android and iOS SDKs read:
| Key | Effect |
|---|---|
trackingId | Used for the delivery receipt and for click tracking. Absent on test sends, which are not counted |
jry_deep_link | The page a click opens (the site root when absent) |
jry_image_url | Large image, in browsers that show one |
jry_api_origin | Where the worker sends the receipt and the click |
jry_campaign_id | The campaign ID, or the journey ID for a journey send. Informational |
SDK authentication
Optional. When it is on, every request carries a JWT your backend mints, as an X-Joryio-Auth header, and the SDK refreshes it when the API rejects it. The token proves who an identified visitor is: anonymous visitors keep working without one.
Your backend signs the JWT with a key you register with Joryio, with a sub matching the user ID you pass to identify(). A bad, expired or missing token is answered 401 { error: 'sdk_authentication_error', reason: 'missing' | 'invalid' | 'expired' | 'sub_mismatch' }.
const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_YOUR_KEY',
enableSdkAuthentication: true,
sdkAuthenticationToken: await mintJoryioJwt(),
});
// Fires on a 401 sdk_authentication_error. Return a fresh token (or call
// setSdkAuthenticationToken); the failed request is then retried once.
joryio.setSdkAuthenticationErrorCallback(async (info) => {
// info = { reason, endpoint, userId }
return await mintJoryioJwt();
});
// Replace the token at any time, e.g. before it expires
joryio.setSdkAuthenticationToken(await mintJoryioJwt());
| Method | Description |
|---|---|
setSdkAuthenticationToken(token) | Set or replace the JWT. Ignored (with a warning) unless enableSdkAuthentication is true |
setSdkAuthenticationErrorCallback(callback) | callback(info) runs on a rejected token; return a fresh token, or a promise of one |
- The callback fires on an SDK-auth
401for the current user's request whileenableSdkAuthenticationis on - including before any token was set (reason: 'missing'). It never fires for a request of a user who has since logged out, so it cannot make your app sign out the person now using it. - One immediate retry. The failed request is retried once with the new token. If there is no callback, it returns no token, or the retry fails too, nothing is dropped: the events stay queued and are retried with the normal backoff, carrying whatever token is set by then. A profile write is retried at most 10 times.
- A logged-out user's token is kept for their leftovers. At
reset()- oridentify()of another user - the token stops being the session's but stays bound to the outgoing user's queued events and writes, kept inlocalStoragewith them so a reload does not lose it, and deleted once the last of them is delivered or dropped. A token you set for the incoming user before callingidentify()is recognised by itssuband stays theirs. Setting a token whosesubis another user sets aside the token it replaces the same way, so the order does not matter: the outgoing user's leftovers keep the outgoing user's token. Refreshing the same user's token sets nothing aside. A token set aside is kept at least a minute and at most 30 days. A token whosesubis not the current user's is kept for that user this way also when no one is signed in, untilidentify()makes it the session's;wipeData()deletes such a token. - One user per request. Events are never batched across a change of user.
reset()takes the token off the session; it stays with the outgoing user's own queued work. Events and writes the outgoing user had already queued still go out with that user's token, so a normal logout loses nothing. Events, anidentify()and profile writes of a user who is no longer the current one, refused with any SDK-auth401(missing,sub_mismatch,expiredorinvalid), are dropped - no token for that user will be set again - so they never hold up the next user's. A request carries only a token whosesubis that request's user: the current token when it is theirs, else the token set aside for them, else none - so a token you set for the next user beforeidentify()never goes out on the current user's requests. Anonymous requests carry no token.
Subscription preferences
The write-only user object updates a visitor's marketing subscriptions. Changes wait for a pending identify() to reach the server, carry both the user ID and the anonymous ID, and are retried if the contact does not exist yet. A change made before identify() is sent with the user ID once the identify has reached the server, so SDK authentication accepts it. Changes are kept in localStorage until acknowledged and delivered in order: a newer change to a channel or group replaces an older one still waiting. An unsubscribe is delivered even while the visitor is opted out or consent is denied.
import { SubscriptionStatus } from '@joryio/web-sdk';
joryio.user.setSubscription('email', SubscriptionStatus.OPTED_IN);
joryio.user.setSubscriptions({
email: SubscriptionStatus.OPTED_IN,
sms: SubscriptionStatus.UNSUBSCRIBED,
});
joryio.user.addToSubscriptionGroup('newsletter-list-id', 'email');
joryio.user.removeFromSubscriptionGroup('newsletter-list-id', 'email');
| Method | Description |
|---|---|
user.setSubscription(channel, status) | One channel |
user.setSubscriptions(preferences) | Several channels, as { channel: status } |
user.addToSubscriptionGroup(groupId, channel?) | Join a subscription group (list); channel defaults to 'email' |
user.removeFromSubscriptionGroup(groupId, channel?) | Leave a subscription group |
Channels: email, sms, whatsapp, push, viber. Statuses: SubscriptionStatus.OPTED_IN (explicit opt-in, e.g. double opt-in confirmed), SubscriptionStatus.SUBSCRIBED (single opt-in) and SubscriptionStatus.UNSUBSCRIBED. With the CDN build, pass the strings 'optedIn', 'subscribed' or 'unsubscribed'.
Package exports
| Export | What it is |
|---|---|
default / JoryioSDK | The SDK class (window.Joryio in the CDN build) |
SubscriptionStatus | The subscription statuses above |
User, PushManager, EcommerceTracker | The classes behind joryio.user, joryio.push and joryio.ecommerce() - for types; you do not construct them |
createEcommerceTracker(sdk, config?) | The same as sdk.ecommerce(config) |
SDK_VERSION | The SDK version string, e.g. '1.3.3' |
| Types | InitConfig, InAppOptions, InAppDisplayOptions, InAppCampaign, InAppMessage, InAppTrigger, Plugin, TrackingConsent, PushPermissionState, PushSubscribeOptions, SubscriptionChannel, SubscriptionPreferences, UTMParameters, UTMData, EcommerceProduct, EcommerceCartItem, EcommerceOrder, EcommerceConfig, SdkAuthFailureInfo, SdkAuthFailureReason, SdkAuthFailureCallback |
installSnippetQueue is exported for the SDK's own tests; do not call it.
Automatic Session Data
The Web SDK automatically enriches Session Start events with device and environment data. These values are saved to the user profile and device records:
$user_agent$timezone$screen_width/$screen_height$viewport_width/$viewport_height$language/$languages$platform$browser$device_idcountry(ISO-3166-1 alpha-2, derived from IP at session start)
Event Properties
Standard Properties
Every stored event carries these properties - $device_id is attached by the SDK, and the rest are added by Joryio's ingestion pipeline when the event is received:
$device_id: Stable per-browser device ID (attached by the SDK)$session_id: Current session ID$anonymous_id: Anonymous user ID (before identification)$app_id: Your app ID$app_name: Your app name$platform: Always 'web' for Web SDK$is_identified: Whether the user is identified
The $ prefix is reserved - do not use it for your own properties.
Custom Properties
You can add any custom properties to your events:
joryio.track('Video Played', {
video_id: 'vid_123',
video_title: 'Product Demo',
duration: 120,
autoplay: false,
// Any other custom data
});
Best Practices
1. Initialize Early
Initialize the SDK as early as possible in your application:
// In your main app file
import JoryioSDK from '@joryio/web-sdk';
const joryio = new JoryioSDK({
sdkKey: process.env.JORYIO_SDK_KEY,
enableDebug: process.env.NODE_ENV === 'development',
});
2. Track Meaningful Events
Focus on tracking events that matter for your business:
// Good: Specific, actionable events
joryio.track('Trial Started', { plan: 'premium' });
joryio.track('Feature Used', { feature: 'export', format: 'csv' });
// Avoid: Overly generic events
joryio.track('Button Clicked'); // Too generic
3. Use Consistent Naming
Use a consistent naming convention for events and properties:
// Good: Clear, consistent naming
joryio.track('Subscription Upgraded', {
from_plan: 'basic',
to_plan: 'premium',
billing_cycle: 'monthly',
});
// Avoid: Inconsistent naming
joryio.track('upgraded_subscription', {
FromPlan: 'basic',
'to-plan': 'premium',
});
4. Handle User Lifecycle
Properly handle user identification and session management:
// On login
function handleLogin(userId, userInfo) {
joryio.identify(userId);
joryio.setAttributes({
email: userInfo.email,
name: userInfo.name,
});
}
// On logout
function handleLogout() {
joryio.reset();
}
// On signup
function handleSignup(userId, userInfo) {
joryio.identify(userId);
joryio.setAttributes(userInfo);
}
Troubleshooting
Events Not Appearing
- Check your SDK key - Ensure it starts with
jry_sdk_web_(a store integration's key also works). A mobile app's key (jry_sdk_ios_,jry_sdk_android_) is refused: the SDK logs why and stays inactive - Check the console - Enable debug mode to see detailed logs
- Verify initialization - Make sure
new JoryioSDK(config)runs before other methods are called
CORS Errors
The SDK tracking endpoints respond with Access-Control-Allow-Origin: *, so no domain whitelisting is needed. If you still see CORS errors, check that apiEndpoint points at the correct base URL (https://api-eu1.joryio.com/api) and that the request is not being blocked by a browser extension or a proxy that strips CORS headers.
Session Tracking Issues
The SDK uses localStorage for session persistence. Ensure:
- Your site is served over HTTPS (required for secure contexts)
- Users haven't disabled localStorage
- You're not calling
reset()unintentionally
Next Steps
- Web SDK: UTM & Attribution - Campaign attribution, first/last touch, and UTM capture
- E-Commerce Tracking - Product, cart, checkout, and order events
- Track Custom Events
- Set User Attributes
- Create Segments
- Build Campaigns