Skip to main content

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

When to use which
  • /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.

Moved from the API host

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() or wipeData().
  • A queued call cannot return a value. A getter such as getUserId() or isUserOptedOut() returns undefined until the SDK has loaded. To read an ID, or to use user, push or the tracker ecommerce() returns, use ready: joryio.ready(function (sdk) { sdk.getAnonymousId(); }).
  • A failed init loses nothing. If init fails (a missing or malformed SDK key - the reason is logged to the console), window.joryio stays the queue. Call joryio.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() or trackInAppConversion() 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 queued optIn() or updateConsent('granted') does not bring it back. identify() and unsubscribes are kept. The same goes for one queued before a queued optOut() or updateConsent('denied'), or before a wipeData() queued ahead of init. A ready() callback queued while tracking was off, or before a queued optOut() or updateConsent('denied'), still runs, but the track() 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 own optIn() lifts an opt-out, its own updateConsent('granted') lifts a denial, neither lifts the other, and nothing inside it lifts an optOut() or denial queued after it. A wipeData() queued ahead of init does not gate a ready() 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() or updateConsent(), and a wipeData() queued before init, are applied inside init, 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​

Send phone numbers with + and the country code

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​

OptionTypeDefaultDescription
sdkKeystringRequiredYour SDK key from the Joryio dashboard
apiEndpointstringhttps://api-eu1.joryio.com/apiYour region's API host. A bare host works; /api is added
userIdstring-Start with a known user ID
anonymousIdstringgeneratedUse your own anonymous ID
enableDebugbooleanfalseEnable debug logging in console (logLevel: 'debug'). An explicit logLevel wins
batchFlushIntervalnumber5000How 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.
batchSizenumber50Events per request, and the queue size that triggers a send before the timer. Never more than 500 per request (the API's limit)
sendImmediatelybooleanfalseSend each event immediately without batching (not recommended for production)
sessionTimeoutnumber1800000Session 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.
trackSessionStartbooleantrueTrack one "Session Start" event per new session. A reload that resumes the session sends none.
trackPageViewsbooleanfalseAutomatically track "Page Viewed" on page load
captureUTMbooleantrueAutomatically capture UTM parameters from URL for campaign attribution
resetSessionOnNewCampaignbooleanfalseStart a new session when UTM parameters change (useful for campaign-level session analytics)
trackDevicePropertiesbooleantrueInclude device info on Session Start
persistQueuebooleantruePersist queued events to localStorage to survive page reloads
maxQueueSizenumber1000Most events the local queue holds while flushes fail; beyond it the oldest are dropped (with a warning in the log when logging is on)
maxRetriesnumber3Immediate retries of one request (5xx, 429, network error). A batch that still fails is kept and retried later
retryBackoffMsnumber1000Initial retry backoff in milliseconds; doubles per failure, up to 5 minutes
requestTimeoutnumber10000Timeout in milliseconds for each API request (events, attributes, in-app, push)
trackPagePropertiesbooleantrueInclude the referrer and landing page in "Page Viewed" events
respectDoNotTrackbooleantrueTreat the browser's Do Not Track setting as an opt-out
logLevelstring-'error', 'warn', 'info' or 'debug'. Nothing is logged unless this or enableDebug is set
trackingConsentstringunset'granted', 'denied' or 'pending'. Leave it unset if your site has no consent banner. See Privacy and consent
optOutbooleanfalsetrue opts the visitor out, remembered across page loads like optOut(). false does not lift a stored opt-out - only optIn() does
enableSdkAuthenticationbooleanfalseAttach and refresh a JWT your backend mints. See SDK authentication
sdkAuthenticationTokenstring-The first JWT, used when enableSdkAuthentication is true
pluginsarray[]Plugins with beforeTrack / afterTrack hooks (web only)
inApp.enabledbooleantrueTurn in-app messaging on or off
inApp.evaluationModestring'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.syncIntervalnumber0 (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.allowHtmlJsInAppMessagesbooleanfalseAllow 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.defaultPositionstring'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.defaultAnimationstring'fade''fade', 'slide' or 'none': how messages enter. Skipped when the visitor's system asks for reduced motion
inApp.displayOptions.backdropColorstring'rgba(0, 0, 0, 0.5)'Colour of the dim layer behind modal and full-screen messages
inApp.displayOptions.closeButtonColorstringthe message's text colourColour of the close button
inApp.displayOptions.zIndexnumber999999Stacking order of messages (the backdrop sits one below)
inApp.onMessageDisplay / onMessageClick / onMessageDismissfunction-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 batchFlushInterval milliseconds (default 5s)
  • Size-based flush: Sent immediately when batchSize events 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 batchSize events (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: false is a failure: a 2xx response whose body says success: 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 in failedIndices are kept
  • Refused for good means dropped: events the API reports in rejectedIndices are 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 in localStorage until the API acknowledges them, so a navigation or a closed tab does not lose them, and at page unload the ones waiting are handed to fetch(..., { 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 maxQueueSize events (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:
    1. User first loads the page
    2. Session timeout period passes without any events
    3. User calls joryio.reset() (e.g., logout)
    4. New campaign is detected (if resetSessionOnNewCampaign is 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
});
tip

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 trackDeviceProperties is 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_id
  • country (ISO-3166-1 alpha-2, derived from IP at session start)

Use cases:

  1. Welcome journeys: Use Session Start as a journey trigger to reach users when they arrive on your site.

  2. Session-based segments: Session Start events are stored per user, so event-based segment conditions can count them - for example, "performed Session Start at least 10 times" (power users) or "has not performed Session Start in the last 7 days" (re-engagement).

  3. Campaign attribution: Filter analytics on Session Start and group by utm_campaign to 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:

ContentWhat it isHow it renders
NativeStructured data - headline, body, image, buttonsPlain DOM elements, inserted as text nodes. No iframe, no script execution.
HTMLAuthor-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:

  1. what the campaign sets in Style (optional) - written inline on the card
  2. your --joryio-inapp-* values
  3. 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​

  1. Modal - Center of screen with backdrop
  2. Banner - Top of the page
  3. Slide-Up - Small notification from the bottom
  4. Full-Screen - Takeover message
  5. 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
MethodDescription
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: displayed is reported first, then the click or dismissal. Removing the message from code (dismissInAppMessage, reset()) before then reports nothing.
  • onDisplay fires when the message counts as displayed, together with the report - not when it is put on screen. IN_APP_DISPLAY_DWELL_MS is exported by the package.

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
});
StateWhat the SDK does
Consent granted (or never set, with no stored denial)Collects and sends normally
Consent pendingCollects 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 deniedCollects 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 TrackTreated as an opt-out while respectDoNotTrack is true (the default)

How the two switches behave:

  • denied is remembered. It stays in force on later page views until updateConsent('granted') or an explicit trackingConsent: 'granted' in the init config. Leaving trackingConsent unset keeps a stored denial - which is why the option has no default.
  • pending is for one page view. It is never stored, and never overrides a stored denial.
  • Consent never lifts an opt-out. updateConsent('granted') does not undo optOut(); only optIn() does. optOut() and optIn() never change the stored consent.
  • The profile records the opt-out. When the opt-out switch changes - including optOut: true at init - the SDK writes tracking_opted_out (true or false) 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. true is sent even while consent is denied or pending; false waits until tracking is allowed.
  • Withdrawals are always delivered. An unsubscribe (SubscriptionStatus.UNSUBSCRIBED, removeFromSubscriptionGroup()), the tracking_opted_out marker and a push unregister are sent even while the visitor is opted out, with consent denied or pending, and after wipeData(). 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 normal identify() runs when tracking is allowed again. reset() and wipeData() forget it. On a grant or opt-in the kept identify() 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, the tracking_opted_out marker and push.unsubscribe(). An identify() once tracking is allowed replaces it, and an explicit userId in the init config wins over it.
  • Every open tab follows. An opt-out, a consent change, a reset() or an identify() 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 full reset() 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 in pending. Another tab or script calling localStorage.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 in localStorage are merged, never overwritten by one tab. The user an identify() keeps for withdrawals while tracking is off is followed too. An explicit updateConsent('granted') or optIn() made right after such a localStorage.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, and wipeData() in one tab discards the other tabs' waiting writes too (withdrawals excepted).
  • With the async snippet, these calls take effect before init sends 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 event
  • properties (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').

Tracking Page Views

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)
Read it after the SDK has loaded

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, and lastTouch UTM 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 null or undefined value removes the attribute, the same as unsetAttribute(key).
  • A value JSON cannot carry - NaN, Infinity, a BigInt, 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 (default 1), 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 in localStorage until 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 when identify('B') or reset() is called still goes to A.

Array Attributes​

joryio.addToArray(key, value)
joryio.removeFromArray(key, value)

Manipulate array attributes efficiently.

Parameters:

  • key (string): Attribute name
  • value (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 exist
  • removeFromArray() 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).

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();
}
MethodDescription
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:

KeyEffect
trackingIdUsed for the delivery receipt and for click tracking. Absent on test sends, which are not counted
jry_deep_linkThe page a click opens (the site root when absent)
jry_image_urlLarge image, in browsers that show one
jry_api_originWhere the worker sends the receipt and the click
jry_campaign_idThe 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());
MethodDescription
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 401 for the current user's request while enableSdkAuthentication is 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() - or identify() of another user - the token stops being the session's but stays bound to the outgoing user's queued events and writes, kept in localStorage with 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 calling identify() is recognised by its sub and stays theirs. Setting a token whose sub is 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 whose sub is not the current user's is kept for that user this way also when no one is signed in, until identify() 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, an identify() and profile writes of a user who is no longer the current one, refused with any SDK-auth 401 (missing, sub_mismatch, expired or invalid), 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 whose sub is 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 before identify() 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');
MethodDescription
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​

ExportWhat it is
default / JoryioSDKThe SDK class (window.Joryio in the CDN build)
SubscriptionStatusThe subscription statuses above
User, PushManager, EcommerceTrackerThe 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_VERSIONThe SDK version string, e.g. '1.3.3'
TypesInitConfig, 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_id
  • country (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​

  1. 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
  2. Check the console - Enable debug mode to see detailed logs
  3. 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​