Skip to content

from tracking script to sdk

Joseph Samir edited this page May 27, 2026 · 1 revision

From the Tracking Script to the SDK — Translation Reference

When you use a Convert SDK directly instead of the classic Convert tracking script, the SDK becomes the runtime engine for the rules and goals you configured in the Convert UI — but you become the trigger and context provider. Nothing auto-detects URLs, clicks, geolocation, or visitor state for you. You provide them on every call and you decide when each conversion fires.

This page maps Convert UI concepts (goal types, audience rules, location rules, experience types) to the SDK calls and parameters you'd use to replicate them. The mapping is the same across every Convert SDK — JavaScript and PHP today, and the same shape will apply to future SDKs (iOS, Android, etc.) when they ship. Code snippets below are shown side-by-side in JavaScript and PHP where the trigger surface allows; browser-only patterns are explicitly marked.

See also: Server-Side Experimentation, Running Experiences, Tracking Conversions, Rule Evaluation.

The Core Shift

The classic tracking script is opinionated and turnkey. It:

  • Reads the URL and refires evaluation on history.pushState / popstate.
  • Reads the User-Agent and infers browser, OS, and device.
  • Reads cookies (geo, UTM, custom).
  • Watches the DOM for click and form-submission targets matching your goal selectors.
  • Auto-fires viewExp events when the visitor enters a matching site area.
  • Auto-fires hitGoal events when a matching trigger condition occurs.
  • Persists visitor identity across pages via _conv_v.

No SDK does any of those things — by design. Each SDK exposes two surfaces and trusts you with the rest:

  • runExperience / runExperiences — given the context you supply, returns the bucketed variation (and fires an exposure event).
  • trackConversion — given a goal key and any attributes you supply, fires a conversion event.

Everything between (URL change detection, click watching, geo lookup, cookie parsing, consent gating) is your job. The pages below tell you what attribute keys the SDK expects so the rules you configured in the UI still match. The keys are SDK-agnostic — they're properties of the project config you wrote in the Convert UI, not language-specific.

1. Experience Targeting

When you run userContext.runExperience('experience-key') (or runExperiences() to evaluate all active experiences), the SDK walks the experience's locations and audiences rule trees. For each rule, it looks up a key in the attribute object you passed and runs a comparison. If you don't pass that key, the rule can't match and the visitor isn't bucketed.

The SDK accepts two attribute objects on every runExperience(s) call:

Attribute What the SDK reads from it Typical keys
locationProperties Anything tied to where the visitor is — URL parts, referrer, page path, hostname, hash, querystring components. url, path, referrer, hostname, hash, query, etc.
visitorProperties Anything tied to who the visitor is — geo, device, browser, OS, visitor type, UTM tags, custom dimensions, cookies, logged-in state, plan tier, anything you want to target on. country, region, device, browser, os, visitorType, utm_source, utm_medium, plan, isLoggedIn, etc.
const variation = userContext.runExperience('homepage-hero', {
  locationProperties: {
    url: window.location.href,
    path: window.location.pathname,
    referrer: document.referrer
  },
  visitorProperties: {
    country: 'US',
    device: 'desktop',
    browser: 'chrome',
    visitorType: 'returning',
    utm_source: new URLSearchParams(location.search).get('utm_source') ?? undefined
  }
});

The exact keys you need depend on the rules you configured. Inspect your project's experiences[].audiences and experiences[].locations / experiences[].site_area to see which keys each rule references, and make sure your visitorProperties / locationProperties include those keys with the right values.

Common rule-type translations

These are the most common UI rule types and what to pass. If your rule references a key not listed here, just match the key name your rule uses.

UI rule Pass via Typical value source
URL (exact / contains / regex / starts-with / ends-with) locationProperties.url (or path, depending on rule config) window.location.href or your server-side request URL
Hostname locationProperties.hostname window.location.hostname
Query parameter locationProperties.query.<param> or split out at the top level parse window.location.search
Referrer locationProperties.referrer document.referrer
Country visitorProperties.country Geo-IP from your CDN/edge headers (cf-ipcountry, x-vercel-ip-country, oxygen-sub-country) or a server-side geo service
Region / state visitorProperties.region Same source as country
City visitorProperties.city Same source as country
Device type visitorProperties.device ('desktop' / 'mobile' / 'tablet') UA parser (browser or server-side)
Browser visitorProperties.browser (e.g. 'chrome', 'safari', 'firefox') UA parser
Operating system visitorProperties.os (e.g. 'windows', 'macos', 'ios', 'android') UA parser
Visitor type (new / returning) visitorProperties.visitorType Check your own visitor-ID cookie: present → 'returning', absent → 'new'
UTM tag visitorProperties.utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid Parse window.location.search on landing, persist to a cookie for later evaluation
Cookie value visitorProperties.<cookieName> Read the cookie yourself
Logged-in state visitorProperties.isLoggedIn (boolean) Your auth state
Plan / tier / role visitorProperties.plan, visitorProperties.role, etc. Your application state
Custom segment Use runCustomSegments(['segment-key'], { ...properties }) before bucketing — see Visitor Context n/a

If your rule uses a value the SDK couldn't evaluate (because the key wasn't in the attributes you passed), the SDK returns a RuleError.NeedMoreData — not a false match. That's how you tell "the visitor genuinely didn't qualify" from "you forgot to pass the key". The SDK's debug logs surface which keys were missing — see the Troubleshooting page.

URL parsing detail

URL rules in the Convert UI typically operate on a normalized URL string. Most stacks pass locationProperties.url as the full URL (window.location.href in the browser; the equivalent server-side value otherwise). Some rule configs target specific parts (path only, hostname, querystring); in those cases, pass the corresponding sub-key (path, hostname, query) so the rule has what it needs.

If your rules were written against window.location.href, pass window.location.href. If they were written against window.location.pathname, pass that. The SDK doesn't normalize for you — what's in the rule is what gets compared.

2. Experience Types

Fullstack projects support two experience types. Both use the same runExperience / runExperiences calls.

type field on ConfigExperience What it does SDK pattern
a/b_fullstack Standard A/B test. Visitor is bucketed into one variation; you render or apply that variation. Goal hits attribute to the bucketed variation. runExperience('exp-key') → check variation.variationKey and render accordingly.
feature_rollout Feature flag rollout. Variations represent feature states (enabled/disabled, or per-variant variable values). Goals are typically tied to the feature's effect rather than a variant comparison. runFeature('feature-key') → check feature.status ('enabled' / 'disabled') and read feature.variables. See Running Features.

You can run both kinds in the same project. A/B tests use experiment-style reporting; feature rollouts use feature-status reporting.

3. Goal Triggers

In the Convert UI, goals carry conceptual trigger types — "this goal fires when the user clicks a button", "this goal fires when the user reaches /thank-you", and so on. The classic tracking script auto-watches for each trigger type.

In any SDK, all of that is your code's responsibility. There is one method — trackConversion(goalKey, attrs?) — and it fires whenever you call it. The goal's key is the only thing the SDK cares about; everything else (selector watching, URL pattern matching, timing) is the customer's trigger code.

Below are the common goal types you'd find in the UI and one-or-two patterns for replicating each. Each goal type can fire from any SDK — what changes is the surface where the trigger event happens. Where the event has both a browser and a server-side surface (e.g. URL visit → route handler, form submit → POST handler), examples are shown in both JavaScript and PHP. Some triggers are inherently single-surface (clicks are DOM-only; engagement metrics are client-only) and are marked as such, with notes on how future mobile SDKs would express the same idea.

About goal rules: a goal in your project config may have a rules object attached. When you call trackConversion(key, { ruleData: {...} }), the SDK evaluates those rules against your ruleData and only fires the conversion if they match. If the goal has no rules, it always fires when you call. Most "trigger" logic lives in your code, not in the goal config — ruleData is for fine-grained conditional firing, not full trigger detection.

Click goal — element click

Browser-only trigger. A click is a DOM event with no native server-side analogue — the closest server-side equivalent is the action the click eventually causes (form POST, API call), in which case you'd model the goal as a form-submit or URL-visit goal instead.

UI config: a CSS selector. The classic tracking script auto-attaches a delegated click listener and calls hitGoal when a matching element is clicked.

JavaScript SDK pattern — attach the listener yourself:

document.body.addEventListener('click', (e) => {
  if (e.target.closest('.signup-cta')) {
    userContext.trackConversion('signup-cta-click');
  }
});

Delegated to document.body so it survives DOM mutations and SPA re-renders. If your variation code adds and removes the target element repeatedly, the delegated listener still works — no need to re-attach. For dynamic selectors or selector lists, replace .closest('.signup-cta') with whatever query matches your goal's selector spec.

Future mobile SDKs: the same goal-key conversion would fire from a native gesture handler (UITapGestureRecognizer on iOS, View.OnClickListener on Android). The SDK call shape is the same — trackConversion('signup-cta-click') — only the trigger source changes.

URL-visit goal — visitor reaches a specific path

Browser and server both. UI config: a URL pattern. The classic tracking script fires when the visitor lands on a matching page.

Browser pattern — detect navigation and check the path. Initial-load case is easy; SPA case needs hooking the router:

function maybeFireUrlGoal() {
  if (window.location.pathname === '/thank-you') {
    userContext.trackConversion('purchase-confirmation-reached');
  }
}

// Initial load
maybeFireUrlGoal();

// SPA: hook your router. Vanilla fallback:
window.addEventListener('popstate', maybeFireUrlGoal);
const origPush = history.pushState;
history.pushState = function (...args) {
  origPush.apply(this, args);
  maybeFireUrlGoal();
};

For pattern-based matches (contains, regex), substitute the comparison:

if (/\/order\/[a-z0-9-]+\/confirm$/.test(window.location.pathname)) {
  userContext.trackConversion('order-confirmation-reached');
}

Server pattern — fire from the route handler that renders the matching path. The visitor reaches /thank-you only by hitting your server, so the route handler is the natural place:

For many URL goals on the browser side, the equivalent generalization:

const urlGoals = [
  { goalKey: 'purchase-confirmation-reached', match: (p) => p === '/thank-you' },
  { goalKey: 'pricing-page-viewed',           match: (p) => p === '/pricing' },
];
function evaluateUrlGoals() {
  const path = window.location.pathname;
  for (const g of urlGoals) {
    if (g.match(path)) userContext.trackConversion(g.goalKey);
  }
}

The SDK dedups goal conversions per visitor (one fire per visitor per goal unless forceMultipleTransactions: true), so calling on every navigation / request is safe — repeats are silently dropped.

Future mobile SDKs: model URL-visit goals as screen-visit goals — fire from your screen's onAppear / onResume / route-listener hook. Same trackConversion(goalKey) call.

Form-submission goal — visitor submits a form

Browser and server both. UI config: a CSS selector targeting a <form>. The classic tracking script auto-listens on the storefront.

Browser pattern — same idea as click, but on submit:

document.body.addEventListener('submit', (e) => {
  if (e.target.matches('form.newsletter-signup')) {
    userContext.trackConversion('newsletter-signup-submitted');
  }
});

If your form is submitted via AJAX (no actual submit event), call trackConversion directly from your submit handler instead.

Server pattern — fire from the route that processes the form POST. This is often the more reliable place because it only runs after the form actually validates and persists:

// Or in Node/Express server-side
app.post('/newsletter/subscribe', async (req, res) => {
  // ... validate + persist ...
  req.convertContext.trackConversion('newsletter-signup-submitted');
  res.redirect('/newsletter/thanks');
});

Future mobile SDKs: fire from the submit-button handler after your validation completes, same trackConversion call.

Custom / programmatically-triggered goal

Browser and server both. Tracking script: customer calls _conv_q.push({ what: 'tag', params: { ID: '12345' }}) or _conv_q.push({ what: 'trigger-goal', params: { goal_id: '...' }}).

SDK equivalent: just call trackConversion directly. There is no queue, no indirection.

function onSomeBusinessEvent() {
  userContext.trackConversion('business-event-goal-key');
}

This is the most natural fit between the two worlds — anywhere you'd have pushed to _conv_q, you call trackConversion. The shape of the call is identical across every SDK; only the surrounding event handler differs.

Revenue / transactional goal

Browser and server both. UI config: a goal flagged as transactional/revenue-bearing. Tracking script auto-fires when the corresponding trigger (typically a click or URL goal) fires, attaching revenue data the customer set on _conv_q or via convert.currentData.transaction.

SDK pattern: pass conversionData on the trackConversion call. The most reliable place for this is server-side (e.g. an order webhook) because client-side conversions can be blocked or lost on the navigation away from checkout.

userContext.trackConversion('purchase-completed', {
  conversionData: [
    { key: 'amount', value: 99.99 },
    { key: 'productsCount', value: 3 },
    { key: 'transactionId', value: 'order-12345' }
  ]
});

Conversion count (unique visitors who converted) is dedup'd by visitor per goal. Revenue accumulates across distinct transactionId values for the same visitor — so multiple orders from the same customer add up, but the conversion count itself is 1. For subscription renewals where you want every renewal to count as its own transaction, pass conversionSetting: { forceMultipleTransactions: true } — see Tracking Conversions.

Engagement goals (time on page, scroll depth)

Browser-only trigger. Engagement metrics live in the client — a server can't observe how long someone spent on a page or how far they scrolled. Future mobile SDKs would express the same idea via screen-time observers or scroll-listener APIs.

These have no built-in SDK equivalent — the tracking script auto-watches; no SDK does. Implement the threshold yourself in the browser and call trackConversion when the condition is met:

// Scrolled 75% of page goal
let fired = false;
window.addEventListener('scroll', () => {
  if (fired) return;
  const pct = (window.scrollY + window.innerHeight) / document.body.scrollHeight;
  if (pct >= 0.75) {
    userContext.trackConversion('scrolled-75-percent');
    fired = true;
  }
});

// 30 seconds on page goal
setTimeout(() => userContext.trackConversion('engaged-30s'), 30_000);

Keep these listeners idempotent and tear them down on route changes if your stack uses SPA navigation, to avoid duplicate timers across pages.

4. Things the Tracking Script Does That the SDK Does Not

A brief checklist of behaviors you'll re-implement (or accept as unsupported):

Tracking-script behavior SDK equivalent
Auto page-view events on every URL change None — the SDK only fires exposure events on runExperience and conversion events on trackConversion. If you need a page-view-like signal, fire a dedicated custom goal manually.
Visitor cookie generation + 6-month persistence You generate and persist the visitor ID. See Client-Side Experimentation and Server-Side Experimentation for cookie helpers.
Cross-domain visitor linking via signal redirects You plumb the visitor ID across domains yourself (URL parameter on outbound links, or sync server-side).
Audience re-evaluation on SPA navigation Call runExperience / runExperiences again with updated locationProperties after navigation.
Consent queue gating You gate calls behind your consent signal. See Client-Side Experimentation > Consent.
_conv_q API (addListener, tag, addRevenue, …) Call SDK methods directly — there is no queue. Event hooks are available via convertSDK.on(SystemEvents.X, ...); see Event System.
Visual Editor changes (DOM mutations defined in the UI) Not delivered to Fullstack-SDK callers. Variation changes in Fullstack are fullStackFeature change types — feature variables you read in your code, not pre-baked DOM mutations. See Data Model > Variation Changes.
Cross-experience mutual exclusion / experiment groups Configured in the UI; the SDK respects it when bucketing. No additional code needed.

5. Putting Trigger Detection in One Place

If your project has many goals and audiences, scattering trigger logic across components gets unwieldy fast. A central registry pattern keeps things tidy.

Browser — a single registry that binds each goal to its trigger when the SDK is ready:

const goalRegistry = {
  'signup-cta-click': () =>
    delegate('click', '.signup-cta'),

  'newsletter-signup-submitted': () =>
    delegate('submit', 'form.newsletter-signup'),

  'pricing-page-viewed': () =>
    onPathMatch((p) => p === '/pricing'),

  'purchase-completed': () =>
    onCustomEvent('checkout:completed', (e) => ({
      conversionData: [
        { key: 'amount', value: e.detail.total },
        { key: 'transactionId', value: e.detail.orderId }
      ]
    })),
};

// Bind everything once, after the SDK is ready and the context is created.
for (const [goalKey, bind] of Object.entries(goalRegistry)) {
  bind((attrs) => userContext.trackConversion(goalKey, attrs));
}

The helpers (delegate, onPathMatch, onCustomEvent) are whatever utilities fit your stack.

Server — a single class that owns server-side triggers, invoked from middleware, controllers, webhooks, and queue workers as appropriate:

The point is the same in both worlds: keep the mapping between UI-configured goal keys and runtime triggers in one place that mirrors the structure you can see in the Convert UI. This is roughly the responsibility the tracking script took on for you; doing it explicitly in your code makes the contract obvious.

6. When to Re-Run runExperience

The classic tracking script re-evaluates audiences on every URL change. No SDK does this automatically — runExperience runs when you call it.

Browser — three reasonable strategies:

  • Once per session. Bucket every active experience at app boot, store the result, never re-run. Simplest, but audience rules that depend on URL won't catch SPA navigations into newly-eligible pages.
  • On every navigation. Hook your router and call runExperiences again with the updated locationProperties.url. Catches SPA-eligible audiences correctly. Without a configured DataStore, this re-fires the exposure event each time — the server-side metrics processor dedups by visitor+experience, so reports are unaffected, but your wire traffic is N× the visitor count.
  • Configure a DataStore. With a persistent DataStore set on the SDK, the dedup is on the SDK side — re-running runExperience reads the cached decision and skips the wire call. This is the recommended approach if you re-run on every navigation. See Persistent DataStore.

Server — call runExperience(s) once per request that needs the decision, with the request's URL and visitor properties. The SDK's batching + per-request shutdown flush handle the event tally; a DataStore (database, cache, etc.) is what makes the dedup span across requests for the same visitor.

Pick the strategy that matches your traffic and tolerance for duplicate exposure events on the wire. None of them affect the correctness of bucketing decisions — those are deterministic per visitor on every SDK.

Clone this wiki locally